Skip to content

feat: Word definitions after game completion#121

Merged
Hugo0 merged 39 commits into
mainfrom
feat/word-definitions
Feb 22, 2026
Merged

feat: Word definitions after game completion#121
Hugo0 merged 39 commits into
mainfrom
feat/word-definitions

Conversation

@Hugo0
Copy link
Copy Markdown
Owner

@Hugo0 Hugo0 commented Feb 22, 2026

Summary

Closes #99. Shows the daily word's definition in the stats modal after game completion (win or loss).

  • Wiktionary integration: Fetches definitions with fallback chain — native Wiktionary → English Wiktionary REST API → "Look up" link
  • Settings toggle: "Show Definitions" option (default ON), persisted to localStorage
  • AI image generation: Optional DALL-E endpoint behind OPENAI_API_KEY env var — generates watercolor illustrations, cached to disk
  • 65+ language support: Language code mapping for Wiktionary API compatibility

How it works

  1. Game ends (win/loss) → loadDefinition() fires async
  2. Shows loading skeleton in stats modal while fetching
  3. Renders definition card with word, part of speech, meaning, and Wiktionary link
  4. On page reload with completed game, definition loads again

AI Images (opt-in)

  • Set OPENAI_API_KEY env var to enable
  • Endpoint: /<lang>/api/word-image/<word>
  • Only generates for today's word (prevents abuse)
  • Cached to webapp/static/word-images/ (gitignored)
  • Silently disabled when no API key

Test plan

  • pnpm test — 61 tests pass (7 new definition tests)
  • uv run pytest tests/ — 2001 passed, 290 skipped, 4 xfailed
  • pnpm build — builds successfully
  • Manual: play a game → definition appears in stats modal
  • Manual: toggle "Show Definitions" off → no definition card
  • Manual: test with multiple languages (en, de, fr, ar)
  • Manual: dark mode styling
  • Manual: mobile responsiveness

Summary by CodeRabbit

  • New Features

    • In-game Word Info: definitions and optional AI word art, toggles to enable/disable, per-word pages, and revamped Today / Stats / All Languages tabs; improved sharing and language navigation; SEO/hreflang and sitemap enhancements.
  • Tests

    • Added definition-fetch tests.
    • Expanded keyboard-coverage expectations to include German.
  • Chores

    • Added OpenAI and image library dependency, updated ignore list for cached defs/images/stats/history, and added image pre-generation utility.
  • Bug Fixes

    • Fixed sitemap generation and added a 404 page.

Add a definition card to the stats modal that shows the meaning of the
daily word after win/loss. Fetches from native Wiktionary first, falls
back to English Wiktionary REST API, then shows a "Look up" link.

- New definitions.ts module with Wiktionary API client + fallback chain
- "Show Definitions" toggle in Settings (default ON, persisted to localStorage)
- Definition card with loading skeleton, dark mode support
- Server-side AI image generation endpoint (DALL-E, behind OPENAI_API_KEY)
- Images cached to disk, only generated for today's word
- 7 new unit tests for definition fetching
@Hugo0
Copy link
Copy Markdown
Owner Author

Hugo0 commented Feb 22, 2026

@coderabbitai review

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Feb 22, 2026

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Feb 22, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Adds Wiktionary-backed definitions, optional AI-generated word images, per-word anonymous stats, frontend UI toggles and rendering, new types and tests, server routes and disk caching for definitions/images/stats, a word page template, image pregeneration script, and related config/.gitignore updates.

Changes

Cohort / File(s) Summary
Frontend: definitions & tests
frontend/src/definitions.ts, frontend/src/__tests__/definitions.test.ts
New WordDefinition type and client utilities to fetch definitions (native → english → link fallback), HTML stripping/escaping, image fetch/render helpers, loading skeletons, and comprehensive Vitest tests for mapping, fallbacks, HTML stripping, and error cases.
Frontend: game & prefs integration
frontend/src/game.ts, frontend/src/index-app.ts, frontend/src/analytics.ts
Integrates definition/image flows and flags into game lifecycle (loadDefinition, submitWordStats), adds feedbackEnabled and wordInfoEnabled state, updates statsTab values, consolidates Haptic/Sound into feedback, and updates analytics setting types.
Types & UI strings
frontend/src/types/index.ts, webapp/data/default_language_config.json, webapp/data/languages/.../language_config.json
Adds WordDefinition interface and LanguageConfig.ui?; introduces new UI keys for definition/word-art and updates many per-language "share" strings.
Backend: routes, caching & AI image workflow
webapp/app.py
Large additions: date/word-resolution helpers, persistent per-day word caching, cached Wiktionary definition fetch/parsing, image prompt/build + OpenAI image generation with disk cache, file-locked per-word stats storage and dedupe, and many new API and page routes (definition, word-image, word-stats, word pages, sitemaps, robots/llms, 404 handler).
Templates & sitemap
webapp/templates/game.html, webapp/templates/word.html, webapp/templates/404.html, webapp/templates/_hreflang.html, webapp/templates/sitemap_index.xml, webapp/templates/sitemap_main.xml, webapp/templates/sitemap_words.xml, (deleted) webapp/templates/sitemap.xml
Game UI updated with tabbed stats, toggles for Feedback/Word Info, placeholders for definition and AI art; new per-word page template with share/stats; added hreflang partial, sitemap index/main/words templates, and new 404 template; removed old sitemap template.
Scripts: pregenerate images
scripts/pregenerate_images.py
CLI script to pregenerate/cache AI images for language/day ranges using OPENAI_API_KEY, with dry-run and reporting.
Data files: words & blocklists
webapp/data/languages/*/*_daily_words.txt, */*_blocklist.txt
Various content edits: additions/removals in daily word lists and blocklists (e.g., German additions, Croatian blocklist replacement), plus added small-blocklist files for some languages.
Project config & ignore
.gitignore, pyproject.toml, render.yaml, README.md
Ignored new per-language cache dirs under webapp/static/ (word-images/ word-defs/ word-stats/ word-history/), swapped nltk for openai and pillow deps, added DATA_DIR env and disk config for render, and small README TODO added.
Tests: keyboard coverage
tests/test_language_config.py, tests/test_word_lists.py
Expanded keyboard-coverage xfail set to include de alongside ko.

Sequence Diagram(s)

sequenceDiagram
    participant Client as Frontend Client
    participant Game as Game Component
    participant Def as Definitions Module
    participant Native as Native Wiktionary
    participant En as English Wiktionary
    participant Server as Backend (app.py)

    Client->>Game: Game ends / open Today tab
    Game->>Game: check wordInfoEnabled / feedbackEnabled
    alt Definitions enabled
        Game->>Def: fetchDefinition(word, lang)
        Def->>Native: query native Wiktionary
        alt native returns
            Native-->>Def: native definition (source:native)
        else native empty/fail
            Def->>En: query English Wiktionary
            alt english returns
                En-->>Def: english definition (source:english)
            else english empty/fail
                Def-->>Game: link-only fallback (source:link)
            end
        end
        Def-->>Game: WordDefinition
        Game->>Client: renderDefinitionCard / showDefinitionLoading
    end
    alt Word art enabled
        Game->>Server: GET /<lang>/api/word-image/<word>
        Server->>Server: validate, check disk cache
        alt cached
            Server-->>Game: cached image URL
        else not cached and generation allowed
            Server->>Server: build prompt, call OpenAI, save image
            Server-->>Game: generated image URL
        else not allowed
            Server-->>Game: 404 / no image
        end
        Game->>Client: renderWordImage / showImageLoading
    end
    Game->>Server: POST /<lang>/api/word-stats (won, attempts)
    Server->>Server: dedupe by IP, file-lock update stats
    Server-->>Game: 204 (ack)
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

Poem

🐰 I hopped through Wiktionary at dawn and found a word,
I painted it with pixels from a prompt I gently stirred,
cached meanings, little pictures, saved for later play,
stats whispered softly, sitemaps led the way,
a rabbit's tiny patch of joy to brighten someone's day.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 75.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The PR title 'feat: Word definitions after game completion' clearly and specifically describes the main change: adding word definitions display after game completion, which aligns with the primary objective in the changeset.
Linked Issues check ✅ Passed The PR fully implements the objectives from issue #99: showing word definitions after game completion to support language learners, reducing reliance on external lookups, providing language-appropriate definitions, and supporting multiple languages across the codebase.
Out of Scope Changes check ✅ Passed All code changes are within scope: definition fetching/caching, word-image generation, stats collection, sitemap/SEO enhancements, UI toggles, and language config updates are all directly supporting the core feature of showing definitions after game completion.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch feat/word-definitions

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 7

🧹 Nitpick comments (8)
webapp/templates/game.html (1)

397-398: LGTM — minor note on display:none vs v-show.

The definition card uses style="display: none;" for direct DOM manipulation by the definitions JS, rather than Vue's v-show. This is functional but slightly inconsistent with the rest of the template. If definitionsEnabled is already reactive Vue state in game.ts, binding :style or v-show here would be more idiomatic and eliminate the need for explicit DOM manipulation in definitions.ts.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@webapp/templates/game.html` around lines 397 - 398, Replace the inline
style-based hide/show on the definition card with a Vue-controlled binding:
remove style="display: none;" from the <div id="definition-card"> and use
v-show="definitionsEnabled" (or bind :style="{ display: definitionsEnabled ? ''
: 'none' }") so visibility is driven by the reactive definitionsEnabled state
from game.ts; then update definitions.ts to stop manually toggling the DOM
element and instead toggle the definitionsEnabled state (or emit an event) so
Vue manages the element's display.
tests/test_word_lists.py (1)

126-126: Annotate mutable class attribute with ClassVar (Ruff RUF012).

Ruff flags mutable default values on class attributes. Add ClassVar to signal this is a class-level constant.

♻️ Proposed fix
+from typing import ClassVar
 ...
-    KEYBOARD_COVERAGE_XFAIL: set[str] = {"ko", "de"}
+    KEYBOARD_COVERAGE_XFAIL: ClassVar[set[str]] = {"ko", "de"}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/test_word_lists.py` at line 126, Ruff warns that
KEYBOARD_COVERAGE_XFAIL is a mutable class attribute; annotate it as a
class-level constant by changing its type to use typing.ClassVar (i.e.,
KEYBOARD_COVERAGE_XFAIL: ClassVar[set[str]]), and add the necessary import for
ClassVar from typing; update the declaration of KEYBOARD_COVERAGE_XFAIL
accordingly so linters recognize it as a class var rather than a mutable
instance attribute.
tests/test_language_config.py (1)

156-156: Annotate mutable class attribute with ClassVar (Ruff RUF012).

Same pattern as TestKeyboardCoverage in tests/test_word_lists.py — annotate with ClassVar and add a type annotation.

♻️ Proposed fix
+from typing import ClassVar
 ...
-    KEYBOARD_COVERAGE_XFAIL = {"ko", "de"}
+    KEYBOARD_COVERAGE_XFAIL: ClassVar[set[str]] = {"ko", "de"}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/test_language_config.py` at line 156, Annotate the mutable class
attribute KEYBOARD_COVERAGE_XFAIL as a ClassVar with an explicit type: change
KEYBOARD_COVERAGE_XFAIL to something like KEYBOARD_COVERAGE_XFAIL:
ClassVar[Set[str]] = {"ko", "de"} and ensure you import ClassVar and Set from
typing (or typing_extensions if used elsewhere) so the mutable set is properly
typed at class level (same pattern as TestKeyboardCoverage in
tests/test_word_lists.py).
webapp/app.py (2)

797-800: Wasteful Language instantiation just to read daily_word.

Line 798 constructs a full Language object (builds keyboard layouts, diacritic hints, etc.) solely to read daily_word. The module-level loaded data is sufficient to compute this directly.

♻️ Proposed refactor
-    # Only allow image generation for today's word
-    word_list = language_codes_5words[lang_code]
-    lang = Language(lang_code, word_list)
-    if word.lower() != lang.daily_word.lower():
+    # Only allow image generation for today's word
+    todays_idx = get_todays_idx(language_configs[lang_code].get("timezone_offset", 0))
+    word_list = language_codes_5words[lang_code]
+    blocklist = language_blocklists[lang_code]
+    if todays_idx <= MIGRATION_DAY_IDX:
+        daily_word = get_daily_word_legacy(word_list, set(), todays_idx)
+    else:
+        daily_word = _get_daily_word_for_lang(lang_code, todays_idx)  # or inline the logic
+    if word.lower() != daily_word.lower():

Alternatively, cache the Language instances at module level per language (keyed by day index) so rebuilds are avoided.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@webapp/app.py` around lines 797 - 800, The code needlessly instantiates
Language(lang_code, word_list) just to access lang.daily_word; avoid rebuilding
heavy Language objects by computing the daily word directly from the
module-level data (language_codes_5words and whatever day-index logic produces
daily_word) or by introducing a module-level cache of Language instances keyed
by (lang_code, day_index). Replace the direct Language(...) call in this block
with either a direct lookup of the daily word using the same day-index algorithm
used by Language.daily_word, or fetch the Language from the cache (e.g.,
get_cached_language(lang_code, day_index)) and then compare word.lower() to that
cached.daily_word.lower().

782-800: No rate limiting — first request per day can trigger OpenAI image generation costs.

The only abuse guard is the daily-word check (Line 799). Any user who observes today's word (e.g. after winning) can make repeated first-time requests across languages, and because the cache check only prevents re-generation for the same (lang_code, word) tuple, a bad actor cycling through language codes could trigger many DALL-E generations per day at significant API cost.

Consider adding at minimum a per-IP rate limit or requiring a server-side secret header for this endpoint.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@webapp/app.py` around lines 782 - 800, The word_image endpoint allows
unauthenticated callers to trigger OpenAI image generation for today's word (via
OPENAI_API_KEY) and is only protected by the daily-word check, so add
server-side abuse protection by enforcing a per-IP rate limit or requiring a
server-side secret header before any generation happens; update the word_image
handler to check a rate limiter keyed by request.remote_addr (or validate a
shared secret header like X-Internal-Secret) immediately after validating
lang_code and before any image-generation/cache-miss path, and ensure the
limiter state is stored server-side (in-memory with expiration, Redis, or
Flask-Limiter) and returns 429 on excess requests to prevent cycling across
language_codes_5words and repeated triggers for Language.daily_word.
frontend/src/definitions.ts (1)

94-98: Optional: use DOMParser instead of a detached div for stripHtml

Setting innerHTML on a detached element is safe in practice (scripts don't execute), but DOMParser is the explicitly documented safe API for parsing HTML to plain text.

♻️ Suggested refactor
 function stripHtml(html: string): string {
-    const div = document.createElement('div');
-    div.innerHTML = html;
-    return div.textContent || div.innerText || '';
+    return new DOMParser().parseFromString(html, 'text/html').body.textContent ?? '';
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/src/definitions.ts` around lines 94 - 98, Replace the detached
div-based HTML stripping in the stripHtml function with DOMParser: parse the
html string using new DOMParser().parseFromString(html, 'text/html'), then
return the parsed document's textContent (or fallback to an empty string);
update the function stripHtml to use this parsed document to extract plain text
instead of creating a detached div and setting innerHTML.
frontend/src/game.ts (1)

1284-1286: Add a .catch() to the fetchDefinition promise chain

fetchDefinition is designed to never reject, but attaching a .catch() is defensive practice and avoids a potential unhandled rejection if the implementation ever changes.

♻️ Suggested change
-                fetchDefinition(this.todays_word, langCode).then((def) => {
-                    renderDefinitionCard(def, container, uiStrings);
-                });
+                fetchDefinition(this.todays_word, langCode)
+                    .then((def) => {
+                        renderDefinitionCard(def, container, uiStrings);
+                    })
+                    .catch(() => {
+                        // fetchDefinition should never reject, but clear loading state if it does
+                        container.style.display = 'none';
+                    });
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/src/game.ts` around lines 1284 - 1286, The promise returned by
fetchDefinition(this.todays_word, langCode) should have a .catch() to prevent
unhandled rejections; update the chain that currently calls
renderDefinitionCard(def, container, uiStrings) so that any error from
fetchDefinition is caught, logged (e.g., console.error or app logger) and a safe
fallback is used (for example call renderDefinitionCard with a null/empty def or
hide the definition UI using container/uiStrings) to keep UI stable; ensure you
modify the invocation site that references fetchDefinition,
renderDefinitionCard, container, uiStrings, this.todays_word and langCode.
frontend/src/__tests__/definitions.test.ts (1)

1-10: Consider adding tests for renderDefinitionCard and showDefinitionLoading

The test suite covers fetchDefinition thoroughly, but the two exported rendering functions have no test coverage despite being part of the public API introduced in this PR.

Would you like me to draft test cases for renderDefinitionCard (link-only vs full definition card, partOfSpeech absent/present) and showDefinitionLoading?

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/src/__tests__/definitions.test.ts` around lines 1 - 10, Add unit
tests for the two uncovered exports: renderDefinitionCard and
showDefinitionLoading. Create tests that assert renderDefinitionCard returns the
expected DOM/HTML for both "link-only" (when only a URL/title are provided) and
"full definition" variants, and check behavior when partOfSpeech is absent
versus present (use the exported renderDefinitionCard function to render and
compare strings or DOM nodes). Also add tests for showDefinitionLoading to
verify it toggles/shows the loading UI as expected (call
showDefinitionLoading(true/false) and assert the DOM contains/removes the
loading indicator). Use the existing test file setup (import
renderDefinitionCard and showDefinitionLoading alongside fetchDefinition, reuse
vi.stubGlobal('fetch') and beforeEach) and create separate it blocks for each
scenario.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@frontend/src/__tests__/definitions.test.ts`:
- Line 131: The test reads mockFetch.mock.calls[0][0] which can be undefined;
ensure the mock call exists before indexing: add an assertion like
expect(mockFetch).toHaveBeenCalled() (or check mockFetch.mock.calls.length > 0)
and then safely access the first call (e.g., use a non-null assertion or
destructure the first call) so that const url = mockFetch.mock.calls[0][0] as
string no longer triggers TS2532; update the test around the mockFetch usage in
definitions.test.ts to perform that presence check or assertion before using
mockFetch.mock.calls[0].

In `@frontend/src/definitions.ts`:
- Around line 239-258: The code currently interpolates external values into
container.innerHTML (posHtml, def.definition, def.word, def.url) which allows
XSS; add a small HTML-escape helper (e.g., escapeHtml) and use it on
def.partOfSpeech, the stripped def.definition, and def.word before
interpolation, and avoid injecting URLs via string HTML—set the anchor's
href/title via DOM properties or validate/encode def.url instead; alternatively
build the DOM with createElement/textContent for the word/definition and set
anchor.href to the sanitized URL rather than using container.innerHTML.

In `@frontend/src/game.ts`:
- Around line 1253-1268: The toggleDefinitions method should re-trigger a
definition load when the user turns definitions back on mid-session and the game
has already finished; inside toggleDefinitions (after the localStorage write and
analytics.trackSettingsChange), add a conditional that if
this.definitionsEnabled is true and this.gameOver is true then call
this.loadDefinition() (ensure it runs inside the existing this.$nextTick
callback so DOM state is settled). This uses the existing toggleDefinitions,
definitionsEnabled, gameOver, and loadDefinition symbols to locate where to add
the call.
- Around line 1263-1266: The call to analytics.trackSettingsChange uses setting
'definitions' but the SettingsChangeParams type (SettingsChangeParams) does not
include it; update the SettingsChangeParams union to include 'definitions' (so
the setting union becomes 'dark_mode' | 'haptics' | 'sound' | 'definitions') so
trackSettingsChange accepts this value; modify the type in analytics where
SettingsChangeParams is declared and ensure any related callers/types compile
after the change.

In `@webapp/app.py`:
- Line 832: The call to urllib.request.urlretrieve(image_url, cache_path) can
hang and doesn't validate the URL scheme; replace it by validating
image_url.scheme == "https" (or otherwise ensure http/https as allowed) then
open the URL with urllib.request.urlopen(image_url, timeout=...) and stream the
response to cache_path, handling exceptions (URLError, HTTPError,
socket.timeout) and closing the response; update any surrounding logic that
expects urlretrieve semantics (e.g., file creation) to write the response bytes
to cache_path and return/raise appropriate errors on failures.
- Around line 835-837: The current broad "except Exception as e" block should be
replaced with targeted exception handling: change the catch to only the expected
error types (e.g., IOError/OSError, ValueError, and any project-specific
ImageGenerationError used by the image generation routine) and handle those by
logging and returning the 500; do not swallow system exceptions—add an explicit
"except BaseException: raise" (or simply re-raise) to let
KeyboardInterrupt/MemoryError and other critical exceptions propagate. Locate
the existing "except Exception as e" in the image generation handler and update
it to "except (IOError, OSError, ValueError, ImageGenerationError) as e: ..."
and ensure any other exceptions are re-raised.
- Around line 804-834: The generated image URL from client.images.generate
(DALL·E 3) is PNG but the code saves it as .webp (cache_path built from
cache_dir and f"{word.lower()}.webp") and then serves it via
app.send_static_file, causing a content-type mismatch; fix by saving and serving
the file with the correct extension (e.g., change the cached filename from .webp
to .png when building cache_path and when calling app.send_static_file for the
cached file) or, if WebP is required, download the PNG (via
urllib.request.urlretrieve using image_url) and convert it to WebP using an
image library (e.g., Pillow) before writing and serving, ensuring
send_static_file and the stored filename reflect the actual output format.

---

Nitpick comments:
In `@frontend/src/__tests__/definitions.test.ts`:
- Around line 1-10: Add unit tests for the two uncovered exports:
renderDefinitionCard and showDefinitionLoading. Create tests that assert
renderDefinitionCard returns the expected DOM/HTML for both "link-only" (when
only a URL/title are provided) and "full definition" variants, and check
behavior when partOfSpeech is absent versus present (use the exported
renderDefinitionCard function to render and compare strings or DOM nodes). Also
add tests for showDefinitionLoading to verify it toggles/shows the loading UI as
expected (call showDefinitionLoading(true/false) and assert the DOM
contains/removes the loading indicator). Use the existing test file setup
(import renderDefinitionCard and showDefinitionLoading alongside
fetchDefinition, reuse vi.stubGlobal('fetch') and beforeEach) and create
separate it blocks for each scenario.

In `@frontend/src/definitions.ts`:
- Around line 94-98: Replace the detached div-based HTML stripping in the
stripHtml function with DOMParser: parse the html string using new
DOMParser().parseFromString(html, 'text/html'), then return the parsed
document's textContent (or fallback to an empty string); update the function
stripHtml to use this parsed document to extract plain text instead of creating
a detached div and setting innerHTML.

In `@frontend/src/game.ts`:
- Around line 1284-1286: The promise returned by
fetchDefinition(this.todays_word, langCode) should have a .catch() to prevent
unhandled rejections; update the chain that currently calls
renderDefinitionCard(def, container, uiStrings) so that any error from
fetchDefinition is caught, logged (e.g., console.error or app logger) and a safe
fallback is used (for example call renderDefinitionCard with a null/empty def or
hide the definition UI using container/uiStrings) to keep UI stable; ensure you
modify the invocation site that references fetchDefinition,
renderDefinitionCard, container, uiStrings, this.todays_word and langCode.

In `@tests/test_language_config.py`:
- Line 156: Annotate the mutable class attribute KEYBOARD_COVERAGE_XFAIL as a
ClassVar with an explicit type: change KEYBOARD_COVERAGE_XFAIL to something like
KEYBOARD_COVERAGE_XFAIL: ClassVar[Set[str]] = {"ko", "de"} and ensure you import
ClassVar and Set from typing (or typing_extensions if used elsewhere) so the
mutable set is properly typed at class level (same pattern as
TestKeyboardCoverage in tests/test_word_lists.py).

In `@tests/test_word_lists.py`:
- Line 126: Ruff warns that KEYBOARD_COVERAGE_XFAIL is a mutable class
attribute; annotate it as a class-level constant by changing its type to use
typing.ClassVar (i.e., KEYBOARD_COVERAGE_XFAIL: ClassVar[set[str]]), and add the
necessary import for ClassVar from typing; update the declaration of
KEYBOARD_COVERAGE_XFAIL accordingly so linters recognize it as a class var
rather than a mutable instance attribute.

In `@webapp/app.py`:
- Around line 797-800: The code needlessly instantiates Language(lang_code,
word_list) just to access lang.daily_word; avoid rebuilding heavy Language
objects by computing the daily word directly from the module-level data
(language_codes_5words and whatever day-index logic produces daily_word) or by
introducing a module-level cache of Language instances keyed by (lang_code,
day_index). Replace the direct Language(...) call in this block with either a
direct lookup of the daily word using the same day-index algorithm used by
Language.daily_word, or fetch the Language from the cache (e.g.,
get_cached_language(lang_code, day_index)) and then compare word.lower() to that
cached.daily_word.lower().
- Around line 782-800: The word_image endpoint allows unauthenticated callers to
trigger OpenAI image generation for today's word (via OPENAI_API_KEY) and is
only protected by the daily-word check, so add server-side abuse protection by
enforcing a per-IP rate limit or requiring a server-side secret header before
any generation happens; update the word_image handler to check a rate limiter
keyed by request.remote_addr (or validate a shared secret header like
X-Internal-Secret) immediately after validating lang_code and before any
image-generation/cache-miss path, and ensure the limiter state is stored
server-side (in-memory with expiration, Redis, or Flask-Limiter) and returns 429
on excess requests to prevent cycling across language_codes_5words and repeated
triggers for Language.daily_word.

In `@webapp/templates/game.html`:
- Around line 397-398: Replace the inline style-based hide/show on the
definition card with a Vue-controlled binding: remove style="display: none;"
from the <div id="definition-card"> and use v-show="definitionsEnabled" (or bind
:style="{ display: definitionsEnabled ? '' : 'none' }") so visibility is driven
by the reactive definitionsEnabled state from game.ts; then update
definitions.ts to stop manually toggling the DOM element and instead toggle the
definitionsEnabled state (or emit an event) so Vue manages the element's
display.

Comment thread frontend/src/__tests__/definitions.test.ts Outdated
Comment thread frontend/src/definitions.ts Outdated
Comment thread frontend/src/game.ts Outdated
Comment thread frontend/src/game.ts
Comment thread webapp/app.py Outdated
Comment thread webapp/app.py Outdated
Comment thread webapp/app.py Outdated
- New "Word Art" toggle in Settings (default ON)
- Fetches AI-generated image via HEAD check, renders above definition
- Loading skeleton while image loads, hides silently if unavailable
- Separate from "Show Definitions" — users can enable/disable independently
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Feb 22, 2026

Caution

Review failed

The head commit changed during the review from a157da4 to fc3f5bb.

✨ Finishing Touches
  • 📝 Generate docstrings (stacked PR)
  • 📝 Generate docstrings (commit on current branch)
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch feat/word-definitions

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

The English Wiktionary REST API returns definitions keyed by language
code (e.g., 'en', 'de') not language name ('English', 'German').
This was causing all definitions to fall through to the link fallback.

Also adds Word Art toggle in settings and word image container in
stats modal.
- Stats modal now has Today/Stats/Global tabs to reduce clutter
- Today tab shows emoji board, word art, definition, share button
- Stats tab shows guess distribution and language stats
- DALL-E prompt now includes word definition for better images
- Convert DALL-E output to WebP via Pillow for smaller file sizes
- Fix: word art and definitions load independently (were coupled)
- New route /<lang>/word/<day_idx> shows each daily word's page
- Server-side definition caching (Wiktionary) for SEO rendering
- OG/Twitter meta tags with AI image as social preview
- Anonymous stats collection (POST /api/word-stats) for difficulty data
- Word page shows: tiles, AI image, definition, community stats, nav
- Sitemap includes last 90 days of word pages per language
- Fix sitemap.xml trailing 's' bug
- Frontend submits game stats after win/loss (fire-and-forget)
- Add Pillow dependency for WebP conversion
- Add get_word_for_day() and idx_to_date() helper functions
- Fix: statsTab reset to 'today' after loading old localStorage state
  (old state had 'language' which matched no v-show condition)
- Fix: Load .env file in app.py for local dev (gunicorn doesn't auto-load)
- Fix: HEAD requests to word-image return 404 if not cached (don't trigger generation)
- Refactor: Image loads via GET directly with onload/onerror handlers
  instead of HEAD check + separate img tag (simpler, works for first-time gen)
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 7

🧹 Nitpick comments (4)
webapp/app.py (3)

874-893: Sitemap generates ~5,850+ URLs on every request.

With 90 days × 65+ languages, each request to /sitemap.xml computes idx_to_date and builds a large list. Consider caching this response (e.g., with a daily TTL) since the content only changes once per day.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@webapp/app.py` around lines 874 - 893, The sitemap handler currently
recomputes a large word_pages list each request by calling get_todays_idx and
idx_to_date for 90 days × many languages; change it to cache the fully rendered
sitemap response (or the computed word_pages) with a daily TTL so it is only
rebuilt once per day. Locate the sitemap generation that calls get_todays_idx,
idx_to_date and render_template("sitemap.xml") and wrap the
computation/rendering with a cache (e.g., Flask-Caching or an in-memory global
cache keyed by date) that returns the cached Response (including Content-Type
header) until the next day; ensure the cache key includes the current day index
from get_todays_idx so it invalidates automatically when the date advances.

751-800: Definition cache has no TTL — stale definitions persist forever.

fetch_definition_cached writes to disk and never expires. If Wiktionary updates a definition or the initial fetch returned an empty result due to a transient error, the stale cache is served indefinitely. Consider adding a TTL check (e.g., re-fetch if cache file is older than 7 days).

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@webapp/app.py` around lines 751 - 800, fetch_definition_cached currently
caches results on disk indefinitely; change it to respect a TTL (e.g., 7 days)
by checking the cache file age before returning it: in fetch_definition_cached,
inspect os.path.getmtime(cache_path) (or store a timestamp in the cached JSON)
and compare to time.time() - TTL_SECONDS, and only return the cached JSON when
it is fresh; if stale, proceed to re-fetch from Wiktionary, update the cache
file with the new result (or empty object) and return the fresh result; ensure
cache write still creates cache_dir and gracefully handles I/O errors and treat
an empty cached object as no definition (None) so transient empty errors get
retried after TTL.

420-439: get_word_for_day duplicates Language._get_daily_word logic — drift risk.

This standalone function mirrors the algorithm in Language._get_daily_word() (lines 617–651). If either copy changes without updating the other, different words will be served on the game page vs. the word page. Consider extracting a shared function that both call.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@webapp/app.py` around lines 420 - 439, get_word_for_day duplicates the
selection logic in Language._get_daily_word, which risks drift; extract the
shared selection algorithm into a single helper (e.g., _select_daily_word or
similar) that encapsulates the MIGRATION_DAY_IDX branching,
curated_schedule/daily_words checks and calls to
get_daily_word_legacy/get_daily_word_consistent_hash using the same parameters
(word_list, blocklist, daily_words, curated_schedule, day_idx, lang_code), then
have both get_word_for_day and Language._get_daily_word call that helper so
behavior remains identical and duplication is removed.
pyproject.toml (1)

12-13: Consider making openai and pillow optional dependencies.

Both packages are only used for the AI image generation feature (the word_image() function), which is silently disabled when OPENAI_API_KEY is not set. Making them hard requirements inflates the install footprint (especially openai with its transitive dependencies like httpx and pydantic) for all deployments. You can move them to an optional dependency group:

♻️ Suggested pyproject.toml change
 dependencies = [
     "flask>=3.1.0",
     "flask-cors>=6.0.0",
     "gunicorn>=24.0.0",
     "Flask-FlatPages>=0.9.0",
     "nltk>=3.9.0",
-    "openai>=2.21.0",
-    "pillow>=12.1.1",
 ]
+
+[project.optional-dependencies]
+ai = [
+    "openai>=2.21.0",
+    "pillow>=12.1.1",
+]

The code already supports this: Pillow has an explicit except ImportError fallback (with graceful degradation to raw PNG), and the openai import is wrapped in a try/except that returns a 500 error on failure. Both patterns indicate these dependencies were intended to be optional.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@pyproject.toml` around lines 12 - 13, Move the hard requirement entries for
"openai" and "pillow" out of the top-level dependencies in pyproject.toml and
add them to an optional dependency group (e.g., [project.optional-dependencies]
image = ["openai>=2.21.0","pillow>=12.1.1"]) so they become installable via
extras (pip install .[image]); keep the runtime behavior unchanged (word_image()
already handles ImportError and the OPENAI_API_KEY gating), and update any
packaging metadata or docs to mention the new optional "image" extra so users
know to install it when they need AI image generation.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@frontend/src/game.ts`:
- Around line 1310-1341: The promise chains in loadDefinition (function
loadDefinition) call fetchDefinition(...).then(...) and
fetchWordImage(...).then(...) without .catch(), so rejections from fetch or from
renderDefinitionCard/renderWordImage can be unhandled; update these chains to
add .catch() handlers that safely handle errors (e.g., log the error, hide or
clear the corresponding container element returned by
document.getElementById('definition-card') / 'word-image-card', and avoid
throwing), or alternatively wrap the render calls in try/catch inside the
.then() and handle exceptions similarly; ensure the fix references
loadDefinition, fetchDefinition, fetchWordImage, renderDefinitionCard and
renderWordImage so the error handling is added for both flows.

In `@webapp/app.py`:
- Around line 823-832: _saving word stats currently lacks synchronization
causing lost updates: update _save_word_stats and _load_word_stats to use
OS-level file locking (e.g., fcntl.flock) around read-modify-write operations
and perform atomic writes by writing to a temp file then os.replace/rename to
the target path; ensure the lock is held while reading and writing the stats
JSON (use the same lock file/path for both functions). Also address the
per-process _stats_seen_ips limitation by moving that state to a shared store
(e.g., Redis) or by coordinating via the same file lock so concurrent Gunicorn
workers don’t overwrite each other.
- Around line 1064-1113: The submit_word_stats handler fails to validate
attempts early and risks corrupting the stats file under concurrent requests;
update submit_word_stats to validate that attempts is an int in 1..6 before
counting a win (use the variables day_idx, attempts, won) and ensure saving is
atomic by changing _save_word_stats/_load_word_stats usage to perform a
load-modify-save protected by a file lock or by writing to a temp file and
os.replace() (or use a cross-process lock like portalocker) so concurrent
workers don't clobber the JSON; also avoid the Ruff TRY300 by moving the final
return "", 200 into the normal control flow (else branch) instead of after the
try, and keep the in-memory dedup map _stats_seen_ips behavior noted (it does
not dedupe across Gunicorn workers).
- Around line 932-936: The code is instantiating Language(lang_code, word_list)
just to check today's word and unnecessarily loads heavy data; replace that by
calling the existing get_word_for_day(lang_code, word_list) (or the helper
function that returns the daily word) and compare word.lower() to that result
instead of creating a Language instance; remove the Language(...) construction
and use get_word_for_day(lang_code, language_codes_5words[lang_code]) for the
comparison.
- Around line 807-808: The _stats_seen_ips dict retains every "lang:day:ip" key
forever; change the implementation so the store is bounded or rotated daily:
replace _stats_seen_ips with a size-limited LRU cache or a dict keyed by day
(e.g., stats_seen_by_day) and clear/remove entries for previous days at
midnight, and ensure the lookup/insert sites use the new structure; reference
the existing symbol _stats_seen_ips and the "lang:day:ip" key format when making
the changes.

In `@webapp/templates/word.html`:
- Around line 38-43: The header contains hardcoded English strings ("Play
Wordle", the H1 prefix, and the date format) which break localization; update
the template to pull these from the same localization object used elsewhere
(e.g., use config.ui.play_wordle, config.ui.wordle_title or similar keys) with
safe fallbacks, e.g. replace the link text, the H1 prefix around {{
lang_name_native }} and any surrounding static labels with {{ config.ui.<key> |
default('Play Wordle') }}-style expressions and keep the dynamic values ({{
lang_code }}, {{ lang_name_native }}, {{ day_idx }}, {{ word_date.strftime('%B
%d, %Y') }}) intact so the strings are localized consistently.
- Around line 169-185: The shareWord() function interpolates Jinja variables
directly into a JS string which can break on quotes/backslashes; update the
template to emit safe JS values by JSON-escaping the Jinja variables (e.g., use
{{ lang_name_native | tojson }}, {{ word | tojson }}, {{ lang_code | tojson }},
{{ day_idx | tojson }}) or assign them to JS variables using tojson and then
build the text string from those variables so special characters are properly
escaped before using navigator.share or clipboard APIs.

---

Duplicate comments:
In `@frontend/src/__tests__/definitions.test.ts`:
- Line 131: The test fails with TS2532 because mockFetch.mock.calls[0] can be
undefined; update the access in the test (where you build url from
mockFetch.mock.calls[0][0]) to use optional chaining on mockFetch.mock.calls and
its first element (and cast/result appropriately) so the expression safely
handles undefined calls and satisfies TypeScript (adjust the assertion to
account for a possibly undefined call if needed).

In `@frontend/src/definitions.ts`:
- Around line 162-203: The renderDefinitionCard function currently interpolates
untrusted Wiktionary fields (def.partOfSpeech, def.word, def.definition,
def.url) directly into container.innerHTML causing XSS and unsafe hrefs; fix it
by building DOM nodes programmatically (createElement, textContent) or
HTML-escaping those fields before injecting, and validate/sanitize def.url to
allow only http/https schemes (reject or transform javascript: and other
dangerous schemes) before setting as an anchor href; update the branches where
container.innerHTML is set (the link-only branch and the detailed-definition
branch) to use safe DOM creation and validated URLs instead of raw string
interpolation.

In `@frontend/src/game.ts`:
- Around line 1275-1278: Update the SettingsChangeParams type used by
analytics.trackSettingsChange to include the missing options; specifically,
modify the union for the setting field in the SettingsChangeParams declaration
so it becomes 'dark_mode' | 'haptics' | 'sound' | 'definitions' | 'word_art' so
calls to analytics.trackSettingsChange (e.g., in game.ts where definitions and
word_art are tracked) compile successfully.
- Around line 1265-1280: The toggleDefinitions() handler needs to re-trigger the
definition load when definitions are turned back on after a finished game: after
persisting definitionsEnabled and calling analytics.trackSettingsChange, add a
guard that if this.definitionsEnabled is true and the game is already finished
(check your game-state flag such as this.gameComplete / this.gameOver /
this.isGameComplete), call this.loadDefinition() (ensure loadDefinition exists
before invoking to avoid errors) so the definition card is re-rendered when
toggled on post-game.

In `@webapp/app.py`:
- Around line 995-1009: The download/conversion path should use a timeout and
preserve correct file type: replace the blocking urlreq.urlretrieve call with
urlreq.urlopen(image_url, timeout=30) and stream the response into the
NamedTemporaryFile (use shutil.copyfileobj or equivalent) before conversion; in
the except ImportError fallback do not rename a raw PNG to a .webp
filename—instead move tmp_path to a cache path whose extension matches the
actual PNG bytes (or derive fallback_cache_path = cache_path with ".png"
extension) so browsers receive correct content, and ensure tmp_path is cleaned
up or nulled after move; update references to urlreq.urlretrieve,
urlreq.urlopen, NamedTemporaryFile, Image.open, except ImportError, cache_path
and tmp_path accordingly.

---

Nitpick comments:
In `@pyproject.toml`:
- Around line 12-13: Move the hard requirement entries for "openai" and "pillow"
out of the top-level dependencies in pyproject.toml and add them to an optional
dependency group (e.g., [project.optional-dependencies] image =
["openai>=2.21.0","pillow>=12.1.1"]) so they become installable via extras (pip
install .[image]); keep the runtime behavior unchanged (word_image() already
handles ImportError and the OPENAI_API_KEY gating), and update any packaging
metadata or docs to mention the new optional "image" extra so users know to
install it when they need AI image generation.

In `@webapp/app.py`:
- Around line 874-893: The sitemap handler currently recomputes a large
word_pages list each request by calling get_todays_idx and idx_to_date for 90
days × many languages; change it to cache the fully rendered sitemap response
(or the computed word_pages) with a daily TTL so it is only rebuilt once per
day. Locate the sitemap generation that calls get_todays_idx, idx_to_date and
render_template("sitemap.xml") and wrap the computation/rendering with a cache
(e.g., Flask-Caching or an in-memory global cache keyed by date) that returns
the cached Response (including Content-Type header) until the next day; ensure
the cache key includes the current day index from get_todays_idx so it
invalidates automatically when the date advances.
- Around line 751-800: fetch_definition_cached currently caches results on disk
indefinitely; change it to respect a TTL (e.g., 7 days) by checking the cache
file age before returning it: in fetch_definition_cached, inspect
os.path.getmtime(cache_path) (or store a timestamp in the cached JSON) and
compare to time.time() - TTL_SECONDS, and only return the cached JSON when it is
fresh; if stale, proceed to re-fetch from Wiktionary, update the cache file with
the new result (or empty object) and return the fresh result; ensure cache write
still creates cache_dir and gracefully handles I/O errors and treat an empty
cached object as no definition (None) so transient empty errors get retried
after TTL.
- Around line 420-439: get_word_for_day duplicates the selection logic in
Language._get_daily_word, which risks drift; extract the shared selection
algorithm into a single helper (e.g., _select_daily_word or similar) that
encapsulates the MIGRATION_DAY_IDX branching, curated_schedule/daily_words
checks and calls to get_daily_word_legacy/get_daily_word_consistent_hash using
the same parameters (word_list, blocklist, daily_words, curated_schedule,
day_idx, lang_code), then have both get_word_for_day and
Language._get_daily_word call that helper so behavior remains identical and
duplication is removed.

Comment thread frontend/src/game.ts
Comment thread webapp/app.py
Comment thread webapp/app.py Outdated
Comment thread webapp/app.py Outdated
Comment thread webapp/app.py
Comment on lines +38 to +43
<header class="text-center mb-6">
<a href="/{{ lang_code }}" class="text-sm text-blue-500 hover:text-blue-600 dark:text-blue-400 dark:hover:text-blue-300">
&larr; Play Wordle {{ lang_name_native }}
</a>
<h1 class="text-2xl font-bold mt-2">Wordle {{ lang_name_native }} #{{ day_idx }}</h1>
<p class="text-sm text-neutral-500 dark:text-neutral-400">{{ word_date.strftime('%B %d, %Y') }}</p>
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot Feb 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Hardcoded English strings break localization for non-English languages.

Strings like "Play Wordle", "Share", "Community Stats", "Players", "Win Rate", "Avg Attempts", "Today", and "Play Today's Wordle" are hardcoded in English throughout this template. The game template uses config.ui.* keys with fallbacks. For consistency and to support the 65+ languages, use the same localization pattern here:

-                &larr; Play Wordle {{ lang_name_native }}
+                &larr; {{ config.ui.play or "Play" }} Wordle {{ lang_name_native }}

(Apply similarly for other hardcoded strings.)

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
<header class="text-center mb-6">
<a href="/{{ lang_code }}" class="text-sm text-blue-500 hover:text-blue-600 dark:text-blue-400 dark:hover:text-blue-300">
&larr; Play Wordle {{ lang_name_native }}
</a>
<h1 class="text-2xl font-bold mt-2">Wordle {{ lang_name_native }} #{{ day_idx }}</h1>
<p class="text-sm text-neutral-500 dark:text-neutral-400">{{ word_date.strftime('%B %d, %Y') }}</p>
<header class="text-center mb-6">
<a href="/{{ lang_code }}" class="text-sm text-blue-500 hover:text-blue-600 dark:text-blue-400 dark:hover:text-blue-300">
&larr; {{ config.ui.play or "Play" }} Wordle {{ lang_name_native }}
</a>
<h1 class="text-2xl font-bold mt-2">Wordle {{ lang_name_native }} #{{ day_idx }}</h1>
<p class="text-sm text-neutral-500 dark:text-neutral-400">{{ word_date.strftime('%B %d, %Y') }}</p>
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@webapp/templates/word.html` around lines 38 - 43, The header contains
hardcoded English strings ("Play Wordle", the H1 prefix, and the date format)
which break localization; update the template to pull these from the same
localization object used elsewhere (e.g., use config.ui.play_wordle,
config.ui.wordle_title or similar keys) with safe fallbacks, e.g. replace the
link text, the H1 prefix around {{ lang_name_native }} and any surrounding
static labels with {{ config.ui.<key> | default('Play Wordle') }}-style
expressions and keep the dynamic values ({{ lang_code }}, {{ lang_name_native
}}, {{ day_idx }}, {{ word_date.strftime('%B %d, %Y') }}) intact so the strings
are localized consistently.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Acknowledged — word.html is a lightweight SEO-focused subpage. Full i18n for this template is a follow-up task. The key content (word, definition) is already localized via the language config.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Rate Limit Exceeded

@Hugo0 have exceeded the limit for the number of chat messages per hour. Please wait 1 minutes and 29 seconds before sending another message.

Comment thread webapp/templates/word.html
…script

- Remove loading="lazy" from detached img element — browsers won't load
  lazy images that aren't in the DOM, causing the skeleton to never resolve
- Set img.src last to ensure onload/onerror handlers are attached first
- Add scripts/pregenerate_images.py for daily cron image pre-generation
- Word-image endpoint now accepts ?day_idx= param to verify historical words
- Word subpage loads images on-demand via endpoint (not just pre-cached)
- OG/Twitter image tags point to endpoint URL for social preview generation
- Image hidden with onerror if generation fails or API key not configured
- Word page returns 404 for today's word (day_idx >= todays_idx)
- Nav "next" link stops at yesterday, shows "Latest" label
- Sitemap excludes today's word pages
- Fix pregenerate script working directory for webapp imports
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 5

🧹 Nitpick comments (5)
scripts/pregenerate_images.py (2)

48-64: Silent failure masks definition fetch problems.

The bare except Exception: pass on line 62 swallows all errors including network misconfigurations. For a batch script meant to be run via cron, logging the error (even to stderr) would help diagnose failures.

Suggested improvement
-    except Exception:
-        pass
+    except Exception as e:
+        print(f"  [warn] Definition fetch failed for {word} ({lang_code}): {e}", file=sys.stderr)
     return None
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@scripts/pregenerate_images.py` around lines 48 - 64, The function
fetch_definition currently swallows all errors with a bare except, making
failures silent; modify fetch_definition to catch Exception as e and log the
exception (including the word and lang_code) to stderr or via the logging module
before returning None so batch/cron runs surface network or parsing issues;
ensure the logging call references fetch_definition context and preserves the
existing return behavior.

26-31: os.chdir() at module import time is a global side effect.

Line 31 changes the process working directory, which could affect any code that imports this module or runs after it. Consider using explicit absolute paths instead, or scoping the chdir inside main().

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@scripts/pregenerate_images.py` around lines 26 - 31, The module-level
os.chdir("_project_root/webapp") is a harmful global side effect; remove the
top-level os.chdir call and instead compute and use explicit absolute paths
(using _script_dir and _project_root) for any file operations or imports, or
move the chdir into a guarded main() function so it only runs when the script is
executed directly; update code that relied on relative paths to use
os.path.join(_project_root, "webapp", ...) and keep the existing
sys.path.insert(0, _project_root) unchanged so imports still work without
changing the process working directory.
frontend/src/definitions.ts (1)

91-131: Native Wiktionary fetch constructs a very long query URL inline — consider extracting parameters.

This is a minor readability nit. The URL on line 96 packs many query parameters into a single string. Consider building it with URLSearchParams for clarity and to avoid encoding issues.

Example
const params = new URLSearchParams({
    action: 'query',
    titles: word,
    prop: 'extracts',
    explaintext: '1',
    exintro: '1',
    format: 'json',
    origin: '*',
});
const url = `https://${wikiLang}.wiktionary.org/w/api.php?${params}`;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/src/definitions.ts` around lines 91 - 131, The inline construction
of the Wiktionary query string in fetchFromNativeWiktionary makes the URL hard
to read and risks encoding mistakes; replace the concatenated query string with
a URLSearchParams object (e.g., build params with keys action, titles, prop,
explaintext, exintro, format, origin) and use `?${params}` to form the final url
while still using getWiktionaryLang(word) for the host and keep returning the
same object shape (word, definition, source: 'native', url:
getWiktionaryUrl(word, lang)); ensure titles uses the raw word (URLSearchParams
will handle encoding) and keep the existing error handling and truncation logic
intact.
frontend/src/game.ts (1)

866-881: Object.assign(this, data) resets statsTab — verify no other transient state is affected.

After loading saved game state via Object.assign, statsTab is explicitly reset to 'today' (line 876). Since SavedGameState doesn't include definitionsEnabled or wordArtEnabled, those preferences (loaded earlier in created()) survive the Object.assign. However, if SavedGameState is ever extended with conflicting keys, this could silently overwrite runtime state.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/src/game.ts` around lines 866 - 881, The loadFromLocalStorage()
method uses Object.assign(this, data) which can overwrite transient runtime
state (e.g., statsTab, definitionsEnabled, wordArtEnabled) if SavedGameState
later adds conflicting keys; update loadFromLocalStorage() to only copy
persistent fields instead of assigning the whole data object — for example,
map/assign individual persisted properties from the parsed SavedGameState into
the instance or whitelist keys to merge, then explicitly reset statsTab =
'today'; reference loadFromLocalStorage(), Object.assign(this, data),
SavedGameState, statsTab, and the created() initialization of
definitionsEnabled/wordArtEnabled when making the change.
webapp/app.py (1)

885-901: Sitemap regenerated on every request for 65+ languages × 90 days.

site_map() computes ~5,850+ word page entries on every request. Consider caching the rendered XML (e.g., with a TTL of a few hours or regenerating once per day) to avoid repeated work.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@webapp/app.py` around lines 885 - 901, The sitemap is being regenerated on
every request (iterating language_codes × 90 days) inside the site_map handler;
add a cache so the heavy work runs only periodically: in site_map() (or near
render_template("sitemap.xml") / make_response) store a module-level
cached_response plus a cached_timestamp or cached_date, and on each request
return the cached_response if it is still fresh (e.g., same day or TTL of a few
hours); otherwise rebuild word_pages using get_todays_idx()/language_codes,
re-render with render_template("sitemap.xml"), update cached_response and
timestamp, and return it.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@frontend/src/game.ts`:
- Around line 1294-1309: The SettingsChangeParams type used by
analytics.trackSettingsChange is missing 'definitions' and 'word_art' keys;
update the SettingsChangeParams.setting union type (in the analytics type
definition) to include those two values so it becomes: 'dark_mode' | 'haptics' |
'sound' | 'definitions' | 'word_art', keeping the rest of the type structure
intact and ensuring any callers like analytics.trackSettingsChange(...) compile
without error.

In `@scripts/pregenerate_images.py`:
- Around line 124-142: urllib.request.urlretrieve blocks indefinitely and
doesn't support timeouts; replace its usage in the download block (where
tmp_path is set) with a urlopen/read pattern that supplies a timeout and writes
the bytes to tmp_path, then continue to use PIL's Image.open(tmp_path) to save
WebP to cache_path; in the ImportError fallback (currently using
shutil.move(tmp_path, cache_path)), do not silently save PNG bytes with a .webp
name—either change cache_path to a .png extension before moving or move to a new
file name (e.g., cache_path_png) and ensure any downstream consumers expect
.png, so update the variable used by the rest of the function accordingly (refer
to tmp_path, cache_path, Image.open, shutil.move, and the PIL ImportError
handler).

In `@webapp/app.py`:
- Around line 986-990: The variable lang_name is assigned from
language_configs[lang_code].get("name", lang_code) but never used, causing an
unused-variable warning; either remove that assignment if the human-readable
name isn't needed, or use lang_name where you currently embed lang_code in the
prompt/metadata so the user-facing text uses the friendly name—update the prompt
construction (the code that builds or formats the prompt near where client =
openai.OpenAI(...) is created) to refer to lang_name instead of lang_code, or
pass lang_name into the prompt/template builder that consumes language
information.
- Around line 20-28: The current .env loader reads _val via _line.partition("=")
and sets it into the environment with os.environ.setdefault without removing
surrounding quotes, so values like OPENAI_API_KEY="sk-abc" keep the quotes;
update the logic that computes _val (referencing _line and _env_path) to strip
whitespace and then remove matching surrounding single or double quotes from the
value before calling os.environ.setdefault (handle empty values and mismatched
quotes gracefully); alternatively replace this loader with python-dotenv if you
prefer a drop-in robust parser.
- Around line 967-983: The inline Wiktionary fetch duplicates logic already
implemented in fetch_definition_cached; replace the try/except block that builds
def_url and parses def_data with a call to fetch_definition_cached(word,
lang_code) (or appropriate signature) and assign its returned string to
definition_hint (falling back to ""), preserving the existing behavior
(timeout/optional nature) and keeping the surrounding comment; ensure you handle
None/empty returns the same way the current code does and remove the duplicated
parsing code (the block that sets raw_def/clean_def/definition_hint).

---

Duplicate comments:
In `@frontend/src/definitions.ts`:
- Around line 170-203: The templates use container.innerHTML with external
fields (def.word, def.definition, def.partOfSpeech, def.url) causing XSS; add an
HTML-escape helper (e.g. escapeHtml(value:string):string) and run def.word,
def.definition, def.partOfSpeech and def.url through it before interpolating
into the template strings used by container.innerHTML (and replace posHtml
construction to use the escaped partOfSpeech). Ensure the helper is used
wherever these def.* values are inserted into the DOM in this file so raw
untrusted text is never injected as HTML.

In `@frontend/src/game.ts`:
- Around line 1266-1281: The build breaks because analytics.trackSettingsChange
in toggleDefinitions passes setting: 'definitions' which isn't included in the
SettingsChangeParams union; update the SettingsChangeParams type (in
frontend/src/analytics.ts) to include the literal 'definitions' in the union so
that the call from toggleDefinitions() and other callers type-checks, and then
re-run type checks to ensure no other callers need adjustments.
- Around line 1311-1326: The promise chain in loadDefinition uses
fetchDefinition(...).then(...) but lacks a .catch(), so any rejection from
fetchDefinition or renderDefinitionCard will be unhandled; update loadDefinition
to append a .catch(...) to the fetchDefinition(...) promise that calls the same
DOM cleanup used on success (e.g., hide or remove the 'definition-card'
container or call a helper such as hideDefinitionLoading) and logs the error
(using console.error or the app logger) to avoid silent failures and ensure the
container is hidden when fetch or render fails.
- Around line 1266-1281: toggleDefinitions currently updates localStorage and
analytics but doesn't re-load definitions when the user turns definitions back
on mid-session; inside toggleDefinitions (the this.$nextTick callback) detect
when this.definitionsEnabled becomes true and call the existing loadDefinition()
method (ensure you call it after updating localStorage/analytics so it runs in
the same nextTick flow), so toggling ON re-invokes loadDefinition().

In `@webapp/app.py`:
- Around line 818-819: _stats_seen_ips currently grows without bounds because
each unique "lang:day:ip" key is kept for process lifetime; replace the plain
dict with a bounded cache that evicts old entries (e.g., an LRU or TTL cache) or
add timestamps + periodic cleanup to expire entries. Specifically, change the
global _stats_seen_ips usage to a cache implementation (or wrap accesses in
functions like mark_seen()/is_seen() that use cachetools.TTLCache or an
OrderedDict-based LRU) with a sensible maxsize and/or ttl so entries for past
days are removed automatically and memory cannot grow unbounded.
- Around line 1012-1036: The urlreq.urlretrieve call in the image download flow
(urlreq.urlretrieve) needs a timeout to avoid blocking the Flask request thread
and the ImportError fallback currently moves a PNG to cache_path and then
returns via app.send_static_file(... .webp) which causes a mismatched
content/extension; fix by using a network call with a timeout (or
urllib.request.urlopen with timeout) to download into tmp_path, handle network
errors and timeouts to return a 504/appropriate error, and change the PIL
fallback in the except ImportError branch (where Image.open is used and
_shutil.move(tmp_path, cache_path) is called) to convert or re-encode the PNG
bytes to a proper WebP file (or alternatively save with the original .png
extension and update the response) so that cache_path actually contains a WebP
before calling
app.send_static_file(f"word-images/{lang_code}/{word.lower()}.webp"); ensure
tmp_path cleanup logic remains intact.
- Around line 834-843: Concurrent writes can corrupt the JSON; update
_save_word_stats and the corresponding _load_word_stats to use an inter-process
file lock and atomic replace: acquire an exclusive lock on stats_path (or a
.lock beside it) before reading or writing, for save write to a temp file in
stats_dir (e.g., stats_path + ".tmp"), fsync the temp file, then
os.replace(temp, stats_path) to atomically swap in the new file, and release the
lock; for load acquire a shared or exclusive lock around opening and json.load
to ensure consistent reads. Ensure the functions reference stats_path and
stats_dir when creating the temp file and lock. Use a cross-platform file-lock
library (e.g., portalocker/lockfile) or fcntl on Unix.

---

Nitpick comments:
In `@frontend/src/definitions.ts`:
- Around line 91-131: The inline construction of the Wiktionary query string in
fetchFromNativeWiktionary makes the URL hard to read and risks encoding
mistakes; replace the concatenated query string with a URLSearchParams object
(e.g., build params with keys action, titles, prop, explaintext, exintro,
format, origin) and use `?${params}` to form the final url while still using
getWiktionaryLang(word) for the host and keep returning the same object shape
(word, definition, source: 'native', url: getWiktionaryUrl(word, lang)); ensure
titles uses the raw word (URLSearchParams will handle encoding) and keep the
existing error handling and truncation logic intact.

In `@frontend/src/game.ts`:
- Around line 866-881: The loadFromLocalStorage() method uses
Object.assign(this, data) which can overwrite transient runtime state (e.g.,
statsTab, definitionsEnabled, wordArtEnabled) if SavedGameState later adds
conflicting keys; update loadFromLocalStorage() to only copy persistent fields
instead of assigning the whole data object — for example, map/assign individual
persisted properties from the parsed SavedGameState into the instance or
whitelist keys to merge, then explicitly reset statsTab = 'today'; reference
loadFromLocalStorage(), Object.assign(this, data), SavedGameState, statsTab, and
the created() initialization of definitionsEnabled/wordArtEnabled when making
the change.

In `@scripts/pregenerate_images.py`:
- Around line 48-64: The function fetch_definition currently swallows all errors
with a bare except, making failures silent; modify fetch_definition to catch
Exception as e and log the exception (including the word and lang_code) to
stderr or via the logging module before returning None so batch/cron runs
surface network or parsing issues; ensure the logging call references
fetch_definition context and preserves the existing return behavior.
- Around line 26-31: The module-level os.chdir("_project_root/webapp") is a
harmful global side effect; remove the top-level os.chdir call and instead
compute and use explicit absolute paths (using _script_dir and _project_root)
for any file operations or imports, or move the chdir into a guarded main()
function so it only runs when the script is executed directly; update code that
relied on relative paths to use os.path.join(_project_root, "webapp", ...) and
keep the existing sys.path.insert(0, _project_root) unchanged so imports still
work without changing the process working directory.

In `@webapp/app.py`:
- Around line 885-901: The sitemap is being regenerated on every request
(iterating language_codes × 90 days) inside the site_map handler; add a cache so
the heavy work runs only periodically: in site_map() (or near
render_template("sitemap.xml") / make_response) store a module-level
cached_response plus a cached_timestamp or cached_date, and on each request
return the cached_response if it is still fresh (e.g., same day or TTL of a few
hours); otherwise rebuild word_pages using get_todays_idx()/language_codes,
re-render with render_template("sitemap.xml"), update cached_response and
timestamp, and return it.

Comment thread frontend/src/game.ts Outdated
Comment on lines +1294 to +1309
toggleWordArt(): void {
this.$nextTick(() => {
try {
localStorage.setItem(
'wordArtEnabled',
this.wordArtEnabled ? 'true' : 'false'
);
} catch {
// localStorage unavailable
}
analytics.trackSettingsChange({
setting: 'word_art',
value: this.wordArtEnabled,
});
});
},
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot Feb 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

rg -n 'SettingsChangeParams' frontend/src/analytics.ts -A5

Repository: Hugo0/wordle

Length of output: 407


🏁 Script executed:

rg -n "setting.*definitions|'definitions'" frontend/src/game.ts

Repository: Hugo0/wordle

Length of output: 108


🏁 Script executed:

sed -n '1270,1285p' frontend/src/game.ts

Repository: Hugo0/wordle

Length of output: 664


Update SettingsChangeParams type to include missing setting keys.

The trackSettingsChange() calls in game.ts use 'definitions' and 'word_art' settings, but these are not included in the SettingsChangeParams.setting type definition in frontend/src/analytics.ts. Extend the union type to:

setting: 'dark_mode' | 'haptics' | 'sound' | 'definitions' | 'word_art';
🧰 Tools
🪛 GitHub Check: Build Check

[failure] 1305-1305:
Type '"word_art"' is not assignable to type '"dark_mode" | "haptics" | "sound"'.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/src/game.ts` around lines 1294 - 1309, The SettingsChangeParams type
used by analytics.trackSettingsChange is missing 'definitions' and 'word_art'
keys; update the SettingsChangeParams.setting union type (in the analytics type
definition) to include those two values so it becomes: 'dark_mode' | 'haptics' |
'sound' | 'definitions' | 'word_art', keeping the rest of the type structure
intact and ensuring any callers like analytics.trackSettingsChange(...) compile
without error.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This was about the settings toggle analytics — the code correctly tracks the setting change. No issue.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Rate Limit Exceeded

@Hugo0 have exceeded the limit for the number of chat messages per hour. Please wait 1 minutes and 20 seconds before sending another message.

Comment thread scripts/pregenerate_images.py Outdated
Comment thread webapp/app.py Outdated
Comment thread webapp/app.py Outdated
Comment thread webapp/app.py Outdated
Comment on lines +986 to +990
try:
import openai

client = openai.OpenAI(api_key=openai_key)
lang_name = language_configs[lang_code].get("name", lang_code)
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot Feb 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Unused variable lang_name (Ruff F841).

lang_name is assigned on line 990 but never referenced. It was likely intended for the prompt but isn't used there.

-        lang_name = language_configs[lang_code].get("name", lang_code)
🧰 Tools
🪛 Ruff (0.15.1)

[error] 990-990: Local variable lang_name is assigned to but never used

Remove assignment to unused variable lang_name

(F841)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@webapp/app.py` around lines 986 - 990, The variable lang_name is assigned
from language_configs[lang_code].get("name", lang_code) but never used, causing
an unused-variable warning; either remove that assignment if the human-readable
name isn't needed, or use lang_name where you currently embed lang_code in the
prompt/metadata so the user-facing text uses the friendly name—update the prompt
construction (the code that builds or formats the prompt near where client =
openai.OpenAI(...) is created) to refer to lang_name instead of lang_code, or
pass lang_name into the prompt/template builder that consumes language
information.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Already fixed — the variable was removed when we refactored word_image() to use shared helpers.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Rate Limit Exceeded

@Hugo0 have exceeded the limit for the number of chat messages per hour. Please wait 1 minutes and 13 seconds before sending another message.

…eanup

- Add DATA_DIR env var for Render persistent disk (/data) with fallback to webapp/static
- Add pending file lock to prevent duplicate DALL-E calls from concurrent requests
- Limit image generation to top 10 languages (IMAGE_LANGUAGES)
- Deduplicate pregenerate_images.py — reuse shared functions from app.py
- Rework Today tab: word (linked to subpage), definition, image, share, timer
- Move emoji grid to Stats tab with second share button
- Add Wiktionary link on word subpages
- Allow today's word on subpages (not just past words)
- Remove unused rembg dependency
- Remove console.log from production code
- Fix XSS: escape all Wiktionary API data before innerHTML interpolation
- Fix TS strict: optional chaining on mock.calls[0]
- Fix .env parser: strip surrounding quotes from values
- Fix unbounded memory: clear _stats_seen_ips daily instead of growing forever
- Fix concurrent stats writes: file locking around read-modify-write cycle
- Fix urlretrieve timeout: use urlopen with 30s timeout for DALL-E downloads
- Fix broad exception: catch specific OpenAI/network errors in image generation
- Fix toggleDefinitions: reload definition when re-enabled after game completion
- Fix unhandled promise: add .catch() to fetchDefinition promise chain
- Fix JS injection: use tojson filter for lang_name_native in word.html script
- Increase IMAGE_LANGUAGES from top 10 to top 30
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@webapp/app.py`:
- Around line 782-821: The function fetch_definition_cached currently writes an
empty dict to cache (json.dump(result or {}, f)) which causes
json.load(cache_path) to return {} instead of the documented None; change the
caching logic so that when no definition was found you write a JSON null (i.e.
dump None) instead of {} and/or normalize the loaded value to None (if
loaded_value == {}: loaded_value = None) so callers always receive None for a
cache miss; update the write site (reference: json.dump(result or {}, f) and
variables cache_path/cache_dir) and the read site (where json.load(f) is
returned) to enforce returning None for missing definitions.

In `@webapp/templates/game.html`:
- Around line 454-459: Close the unclosed <p> tag in the "Next word countdown"
block and remove the extraneous "this." from the Vue binding: ensure the element
containing the countdown inside the div is properly closed (add the missing
</p>) and change v-html="this.time_until_next_day" to
v-html="time_until_next_day" to match other bindings like emoji_board.
- Around line 399-421: The tab UI using the statsTab reactive variable lacks
ARIA roles and attributes; add accessibility by setting role="tablist" on the
container div and role="tab" on each button (the three buttons that set statsTab
to 'today', 'stats', and 'global'), bind aria-selected to true when statsTab
matches the button (e.g., :aria-selected="statsTab === 'today'"), and manage
focusability with tabindex (0 for the selected tab, -1 otherwise, e.g.,
:tabindex="statsTab === 'today' ? 0 : -1"); also consider adding unique
aria-controls ids per tab if corresponding tabpanels exist.

---

Duplicate comments:
In `@frontend/src/game.ts`:
- Around line 1265-1308: The build fails because the
SettingsChangeParams.setting union in analytics.ts does not include
'definitions' and 'word_art', causing calls to analytics.trackSettingsChange
(from functions toggleDefinitions and toggleWordArt) to be a type error; update
the SettingsChangeParams type declaration (the union for the setting field) in
frontend/src/analytics.ts to add 'definitions' and 'word_art' alongside
'dark_mode' | 'haptics' | 'sound' so the calls with setting: 'definitions' and
setting: 'word_art' compile.
- Around line 1310-1335: The fetchDefinition(...).then(renderDefinitionCard)
call inside loadDefinition() can produce unhandled promise rejections if
fetchDefinition rejects or renderDefinitionCard throws; update the block that
uses fetchDefinition(this.todays_word, langCode) so it attaches a .catch(...) to
handle errors (log to console or process logger, call a UI fallback like
hide/remove the loading state shown by showDefinitionLoading, and optionally
render an error state) and ensure any synchronous errors from
renderDefinitionCard are caught by resolving the then callback inside a
try/catch or by using .then(def => { try { renderDefinitionCard(...); } catch
(e) { /* handle */ } }). Refer to loadDefinition, fetchDefinition,
renderDefinitionCard, showDefinitionLoading and the definition-card container
when making the change.
- Around line 1265-1280: When re-enabling definitions or word art the UI never
repopulates after a finished game; update toggleDefinitions and toggleWordArt so
that after you set localStorage and track the change, if the feature is being
enabled (this.definitionsEnabled / this.wordArtEnabled is true) and the
definition/word-art content is not present (or the game is complete), call
this.loadDefinition() / this.loadWordArt() respectively to reload the card; add
the load calls immediately after analytics.trackSettingsChange in
toggleDefinitions (call this.loadDefinition()) and in toggleWordArt (call
this.loadWordArt()).

In `@scripts/pregenerate_images.py`:
- Line 111: The call to urlreq.urlretrieve inside generate_word_image is
blocking without a timeout; update generate_word_image (in webapp/app.py) to
download the image with a request that uses a timeout (e.g., use
urllib.request.urlopen or requests.get with timeout) and stream/save the
response to cache_path instead of calling urlreq.urlretrieve directly, and
ensure exceptions for timeouts/network errors are caught and handled/logged so
the cron job won't hang indefinitely.

In `@webapp/app.py`:
- Around line 979-999: Replace the blocking urlreq.urlretrieve call with a
urlreq.urlopen call that uses a timeout (e.g., urlreq.urlopen(image_url,
timeout=60)) and stream the response into the temporary file (writing
response.read() / iterating chunks into tmp_path) so the Flask thread won’t
hang; update the surrounding try/except/finally in the same block where tmp_path
is created to still unlink tmp_path on error. Also remove the now-dead except
ImportError fallback (the block that imports shutil and moves tmp_path to
cache_path) since pillow>=12.1.1 is guaranteed, keeping only the PIL.Image
open/save logic and the general Exception handler that returns f"error: {e}".
- Around line 828-853: _stats_seen_ips currently grows unbounded; change it to a
bounded per-day structure (e.g., a dict mapping day_idx -> set of ips) and evict
old days and/or enforce a max total entries (LRU or size cap) so keys like
"lang:day:ip" are removed when day advances or cap is exceeded; update all
usages of _stats_seen_ips to check per-day sets. For _load_word_stats and
_save_word_stats implement advisory file locking and atomic replace to avoid
lost updates across Gunicorn workers: acquire an exclusive lock (e.g.,
fcntl.flock) around the read-modify-write sequence in
_load_word_stats/_save_word_stats, write to a temp file then os.replace to
commit, and ensure mkdirs happen before locking; keep function names
_load_word_stats and _save_word_stats and handle/propagate errors instead of
silently passing.

Comment thread webapp/app.py
Comment thread webapp/templates/game.html
Comment thread webapp/templates/game.html
…me card

- Only generate images for day 1708 (Feb 21) onwards — older words serve cache only
- Remove Wiktionary external links from in-game definition card (word subpage has them)
- Hide definition card when no definition found (instead of showing Wiktionary link)
…English)

Portuguese word links to pt.wiktionary.org instead of en.wiktionary.org, etc.
Pass wikt_lang from route to template with lang code mapping.
- Remove "Today's Word" label from Today tab (word itself is enough)
- Re-add arrow link icon on definition card, now points to word subpage
  instead of Wiktionary (word subpage has Wiktionary links)
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🧹 Nitpick comments (3)
frontend/src/definitions.ts (1)

61-92: Add fetch timeouts to prevent indefinitely hung definition requests.

Both fetchFromEnWiktionary and fetchFromNativeWiktionary call fetch(url) with no timeout. A slow Wiktionary response will leave the loading skeleton in the stats modal visible forever — there's no fallback to the "link" source unless the request rejects.

AbortSignal.timeout(ms) is the idiomatic modern fix — it aborts the fetch and throws a TimeoutError which is already caught by the surrounding try/catch:

♻️ Proposed fix
-        const response = await fetch(url);
+        const response = await fetch(url, { signal: AbortSignal.timeout(5000) });

Apply the same change to the fetch(url) call inside fetchFromNativeWiktionary (line 107).

Also applies to: 106-139

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/src/definitions.ts` around lines 61 - 92, fetchFromEnWiktionary (and
fetchFromNativeWiktionary) currently call fetch(url) with no timeout so a slow
Wiktionary response can hang forever; update both functions to create an
AbortSignal via AbortSignal.timeout(ms) (choose a reasonable timeout value) and
pass it to fetch as the signal option, ensuring the fetch will abort on timeout
and the existing try/catch will handle the thrown TimeoutError.
frontend/src/game.ts (1)

1297-1312: toggleWordArt doesn't reload the image when re-enabled after game completion.

Unlike toggleDefinitions (which calls loadDefinition() on re-enable), toggleWordArt has no equivalent logic. A user who disables word art and re-enables it post-game will see a blank image container.

♻️ Proposed fix
                 analytics.trackSettingsChange({
                     setting: 'word_art',
                     value: this.wordArtEnabled,
                 });
+                // Reload image if re-enabled after game completion
+                if (this.wordArtEnabled && this.game_over) {
+                    this.loadDefinition();
+                }
             });
         },
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/src/game.ts` around lines 1297 - 1312, toggleWordArt currently
persists the setting and tracks analytics but doesn’t reload the visual when
re-enabled after a finished game; mirror the pattern used in toggleDefinitions
by invoking the component’s image-loading routine when re-enabling word art
(e.g., add a conditional after setting localStorage: if (this.wordArtEnabled &&
this.gameCompleted) call the existing loadWordArt() / renderWordArt() method
used elsewhere to populate the image container), keeping the localStorage
try/catch and analytics.trackSettingsChange as-is.
webapp/app.py (1)

1188-1190: Broad except Exception swallows unexpected errors in stats submission (Ruff BLE001).

MemoryError, KeyboardInterrupt, and similar should propagate. Narrow the catch to expected failure modes:

♻️ Proposed fix
-    except Exception:
-        return "", 500
+    except (ValueError, KeyError, OSError, IOError):
+        return "", 500
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@webapp/app.py` around lines 1188 - 1190, The catch-all "except Exception"
that returns 500 after the stats submission swallows critical errors; replace it
with a narrow catch for only the expected failure modes (for example ValueError,
json.JSONDecodeError, and requests.exceptions.RequestException) in the
try/except block that currently does "return '', 200" / "except Exception:
return '', 500", and re-raise anything else (or omit it so it propagates).
Ensure you import json and requests.exceptions.RequestException (or the HTTP
client exception type you use) and update the except clause to "except
(ValueError, json.JSONDecodeError, requests.exceptions.RequestException) as e:"
and return the 500 there while letting KeyboardInterrupt, MemoryError,
SystemExit, etc. propagate.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@frontend/src/game.ts`:
- Around line 1279-1282: The condition uses method references (this.gameWon,
this.gameLost) which are always truthy, so replace that check with the boolean
game-over flag: change the guard around loadDefinition() to use this.game_over
(and keep this.definitionsEnabled) so loadDefinition() only runs when
definitions are enabled and the game is actually over; locate the conditional
around loadDefinition() near the existing references to this.definitionsEnabled,
this.gameWon, this.gameLost and update it to check this.game_over instead.

In `@webapp/app.py`:
- Around line 1024-1028: The except ImportError block in webapp/app.py currently
moves the raw PNG at tmp_path to cache_path (which uses a .webp extension),
causing mismatched content-type; update the except ImportError handler for the
Image import so it either re-raises the ImportError (remove the fallback) or
moves/renames tmp_path to a .png cache filename instead of cache_path, and
ensure any downstream use of cache_path/send_from_directory will serve the
correct .png file; locate the ImportError handler referencing tmp_path and
cache_path and modify it accordingly.
- Around line 1015-1016: The code calls urlreq.Request(image_url) and
urlreq.urlopen(req, ...) without validating the URL scheme, which triggers Ruff
S310; update the code around image_url (the value from response.data[0].url) to
perform a one-line scheme check (e.g., parse the URL and ensure scheme ==
"https" or "http" as acceptable) before creating the Request and calling
urlreq.urlopen; if the scheme is invalid, handle it by logging/raising an error
or skipping processing so urlreq.Request and urlreq.urlopen are never invoked
with an unvalidated scheme.

---

Duplicate comments:
In `@frontend/src/game.ts`:
- Around line 1275-1278: The TypeScript union for SettingsChangeParams.setting
is missing 'definitions' and 'word_art', causing analytics.trackSettingsChange
calls in game.ts to fail; update the SettingsChangeParams type in
frontend/src/analytics.ts (the SettingsChangeParams interface/ type and any
related TrackSettingsChange payload type) to include 'definitions' and
'word_art' alongside 'dark_mode' | 'haptics' | 'sound' so that calls to
analytics.trackSettingsChange({ setting: 'definitions', ... }) and
analytics.trackSettingsChange({ setting: 'word_art', ... }) compile.

In `@webapp/app.py`:
- Around line 785-820: The cache currently writes an empty object when no
definition is found, causing json.load to return {} instead of the documented
None; in fetch_definition_cached change the cache write to persist the actual
result (allowing None to be written as JSON null) by replacing json.dump(result
or {}, f) with json.dump(result, f) so subsequent json.load(cache_path) returns
None when no definition was found (update in the block that creates cache_dir
and opens cache_path where variables result, cache_dir, cache_path are used).

---

Nitpick comments:
In `@frontend/src/definitions.ts`:
- Around line 61-92: fetchFromEnWiktionary (and fetchFromNativeWiktionary)
currently call fetch(url) with no timeout so a slow Wiktionary response can hang
forever; update both functions to create an AbortSignal via
AbortSignal.timeout(ms) (choose a reasonable timeout value) and pass it to fetch
as the signal option, ensuring the fetch will abort on timeout and the existing
try/catch will handle the thrown TimeoutError.

In `@frontend/src/game.ts`:
- Around line 1297-1312: toggleWordArt currently persists the setting and tracks
analytics but doesn’t reload the visual when re-enabled after a finished game;
mirror the pattern used in toggleDefinitions by invoking the component’s
image-loading routine when re-enabling word art (e.g., add a conditional after
setting localStorage: if (this.wordArtEnabled && this.gameCompleted) call the
existing loadWordArt() / renderWordArt() method used elsewhere to populate the
image container), keeping the localStorage try/catch and
analytics.trackSettingsChange as-is.

In `@webapp/app.py`:
- Around line 1188-1190: The catch-all "except Exception" that returns 500 after
the stats submission swallows critical errors; replace it with a narrow catch
for only the expected failure modes (for example ValueError,
json.JSONDecodeError, and requests.exceptions.RequestException) in the
try/except block that currently does "return '', 200" / "except Exception:
return '', 500", and re-raise anything else (or omit it so it propagates).
Ensure you import json and requests.exceptions.RequestException (or the HTTP
client exception type you use) and update the except clause to "except
(ValueError, json.JSONDecodeError, requests.exceptions.RequestException) as e:"
and return the 500 there while letting KeyboardInterrupt, MemoryError,
SystemExit, etc. propagate.

Comment thread frontend/src/game.ts Outdated
Comment thread webapp/app.py
Comment thread webapp/app.py Outdated
Many languages had "share" translated as the noun (portion/stock/quota)
instead of the verb (to share/distribute). Fixed 20 mistranslations and
added native translations for 11 languages that were using English default.
Remove dividers between related toggles to reduce clutter:
- Haptic Feedback + Sound Effects grouped together
- Show Definitions + Word Art grouped together
- Merge Haptic Feedback + Sound Effects → single "Sound & Haptics" toggle
- Merge Show Definitions + Word Art → single "Word Info" toggle
- Settings menu: 4 toggles → 2 toggles (plus dark mode and RTL)
- New localStorage keys: feedbackEnabled, wordInfoEnabled
- Updated both game page and homepage settings
Change DALL-E prompt from 3D render to flat vector illustration with
green/white/gray palette matching the app's clean UI. Works better for
abstract words (adjectives, verbs) and looks cohesive with the game.
- Add GET /<lang>/api/definition/<word> endpoint as single source of truth
- Remove all Wiktionary client logic from frontend definitions.ts
- Frontend now calls backend API instead of Wiktionary directly
- Native Wiktionary parser handles 10+ language wiki formats
  (English, German, French, Spanish, Portuguese, Polish, Dutch, Italian, Swedish)
- Fix short-definition bug for Dutch/Greek/Hebrew (lowered threshold, skip example sentences)
- German title-case fallback (lowercase "depot" → tries "Depot")
- Image generation uses English definitions for DALL-E prompt quality
- New _fetch_english_definition() for DALL-E prompts, separate from user-facing native defs
- Fix empty first definition in English Wiktionary (iterate all entries)
- Switch image prompt to painterly style
- Clear stale English-only definition caches for re-fetch with native definitions
Hugo0 added 11 commits February 22, 2026 20:29
- German: mbyte, polit (#57, #41)
- Croatian: vruci, cerek, kadri, smoju, vazda, freud, david, romeo, vinko, jakob (#30-#55)
- Hungarian: izzam (#53)
- Esperanto: arnik (#102)
- English: add "slave" to supplement (#49)

Closes #30, #31, #32, #33, #34, #38, #39, #41, #48, #49, #50, #52, #53, #55, #57, #73, #102
Words added to blocklists must also be removed from the main word
lists and daily_words files, otherwise test_daily_words_not_in_blocklist
fails.
- Add URL scheme validation for wordPageUrl (prevents javascript: injection)
- Pass actual attempt count on loss instead of hardcoded 0
- Move PIL import before temp file creation to prevent leaked tmp files
- Fix critical AttributeError: use language_codes_5words dict instead of
  lang.word_list attribute (languages dict has no such attribute)
- Fix Basque "share" translation (Zati→Partekatu) with trailing space
- Fix Interlingue "share" translation (Compartir→Partir)
Blocklisted words should remain in _5words.txt so they're valid guesses.
The blocklist system already prevents them from being daily words at
runtime — removing them from the word list was redundant and prevented
players from typing them as guesses.
Past daily words are now cached on first access so future word list
changes can never alter historical results. Language._get_daily_word()
delegates to the shared get_word_for_day() to eliminate logic duplication
between the game and word subpages.

Also adds a TODO to README about consolidating per-language data files.
Definition is now fetched client-side via the API endpoint instead of
blocking the page render with a Wiktionary HTTP request. Cached
definitions are still read from disk for OG meta tags (instant).
No more flash of "Look up on Wiktionary" for cached words. Definition
is included in the HTML when the cache exists, JS fetch only runs when
cache is empty (first visit).
Replace single sitemap with sitemap index + paginated child sitemaps.
All ~111K word pages are now indexed (65 langs × 1709 days), split at
50K URLs per file. Recent words get higher priority (1.0→0.3).
- robots.txt with sitemap directive
- llms.txt with project summary and all language links
- JSON-LD WebApplication structured data on homepage
- Canonical URLs on homepage, game pages, and word pages
- Add hreflang tags across all pages (65 languages) via shared partial
- Add server-rendered language links on homepage (crawlable without JS)
- Improve title tags: "Play Wordle in German — Free Daily Word Game"
  instead of "Wordle Deutsch/German" (targets search intent)
- Improve homepage title/description with high-volume keywords
- Add custom 404 page with language links (reduces dead-end bounces)
All 404 returns now go through Flask's error handler, showing the
custom 404 page with language links instead of plain text.
@Hugo0
Copy link
Copy Markdown
Owner Author

Hugo0 commented Feb 22, 2026

@coderabbitai Please do a thorough review of the latest commits, focusing on:

  1. SEO correctness — hreflang implementation (partial _hreflang.html, context processor), canonical URLs, OG meta tags, sitemap index/pagination. Are there any issues with how we handle 65 languages × 1700+ days of word pages?

  2. Word history cachingget_word_for_day() now freezes past daily words to disk (word-history/{lang}/{day_idx}.txt). Is the cache-first approach sound? Any race conditions or edge cases?

  3. Async definition loading — Word subpages server-render definitions from cache when available, fall back to client-side JS fetch when not cached. Is the hybrid approach clean? Any SEO concerns with the JS fallback path?

  4. Sitemap pagination — Sitemap index with paginated word sitemaps (50K URLs per file, ~111K total). Priority gradient from 1.0 (today) to 0.3 (oldest). Any issues with the pagination math or URL ordering?

  5. Securityabort(404) replacing plain-text returns, robots.txt, llms.txt endpoints. Anything exposed that shouldn't be?

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Feb 22, 2026

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Examine the hreflang implementation and context processor
echo "=== Checking for hreflang partial template ==="
fd -t f "_hreflang.html" webapp/templates/

echo -e "\n=== Reading hreflang partial if it exists ==="
if [ -f "webapp/templates/_hreflang.html" ]; then
  cat webapp/templates/_hreflang.html
fi

echo -e "\n=== Checking for context processor for hreflang ==="
rg -n "hreflang|alternate_urls" webapp/app.py -A5 -B5

echo -e "\n=== Checking canonical URL implementation in templates ==="
rg -n "canonical|rel=\"canonical\"" webapp/templates/ -A2 -B2

echo -e "\n=== Checking OG meta tags in word template ==="
if [ -f "webapp/templates/word.html" ]; then
  grep -n "og:" webapp/templates/word.html | head -20
fi

Length of output: 2324


🏁 Script executed:

#!/bin/bash
# Examine word history caching implementation
echo "=== get_word_for_day() function ==="
sed -n '/^def get_word_for_day/,/^def /p' webapp/app.py | head -n -1

echo -e "\n=== Check for word-history cache directory references ==="
rg -n "word-history" webapp/app.py .gitignore

echo -e "\n=== Check for file locking in get_word_for_day ==="
rg -n "fcntl|Lock|LOCK" webapp/app.py | grep -A3 -B3 "word.*history\|get_word_for_day"

Length of output: 1055


🏁 Script executed:

#!/bin/bash
# Examine async definition loading in word template
echo "=== Word template definition rendering section ==="
if [ -f "webapp/templates/word.html" ]; then
  # Look for definition section and JS fetch logic
  sed -n '/<section.*definition/,/\/section>/p' webapp/templates/word.html
  
  echo -e "\n=== Client-side JS for definition fallback ==="
  sed -n '/fetch.*definition\|loadDefinition/,/}/p' webapp/templates/word.html | head -40
fi

echo -e "\n=== Server-side definition rendering in word route ==="
rg -n "@app.route.*word.*day_idx" webapp/app.py -A30 | head -50

Length of output: 2527


🏁 Script executed:

#!/bin/bash
# Examine sitemap pagination implementation
echo "=== Sitemap index route ==="
rg -n "@app.route.*sitemap" webapp/app.py -A50 | head -80

echo -e "\n=== Sitemap templates ==="
fd -t f "sitemap" webapp/templates/

echo -e "\n=== Sitemap index template ==="
if [ -f "webapp/templates/sitemap_index.xml" ]; then
  cat webapp/templates/sitemap_index.xml
fi

echo -e "\n=== Paginated sitemap template ==="
if [ -f "webapp/templates/sitemap_paginated.xml" ]; then
  cat webapp/templates/sitemap_paginated.xml
fi

Length of output: 3644


🏁 Script executed:

#!/bin/bash
# Examine security endpoints and abort usage
echo "=== robots.txt endpoint ==="
rg -n "@app.route.*robots.txt" webapp/app.py -A10

echo -e "\n=== llms.txt endpoint ==="
rg -n "@app.route.*llms.txt" webapp/app.py -A10

echo -e "\n=== Usage of abort(404) replacing plain returns ==="
rg -n "abort\(404\)" webapp/app.py -B2 -A2 | head -40

echo -e "\n=== Check for any remaining plain-text error returns ==="
rg -n "return.*404|return.*\"error\"|return.*400" webapp/app.py | head -20

Length of output: 2193


🏁 Script executed:

#!/bin/bash
# Read the hreflang partial template content
echo "=== _hreflang.html partial template content ==="
cat webapp/templates/partials/_hreflang.html

echo -e "\n=== Check how hreflang is used in templates ==="
rg -n "include.*_hreflang" webapp/templates/

echo -e "\n=== Check if word.html includes hreflang ==="
grep -n "hreflang" webapp/templates/word.html

Length of output: 693


🏁 Script executed:

#!/bin/bash
# Check for SITEMAP_MAX_URLS and complete sitemap_words.xml template
echo "=== SITEMAP_MAX_URLS constant ==="
rg -n "SITEMAP_MAX_URLS" webapp/app.py

echo -e "\n=== Complete sitemap_words.xml template ==="
cat webapp/templates/sitemap_words.xml

echo -e "\n=== sitemap_main.xml template ==="
cat webapp/templates/sitemap_main.xml

Length of output: 997


🏁 Script executed:

#!/bin/bash
# Get complete word template to see definition rendering
echo "=== Complete word.html template ==="
cat webapp/templates/word.html

Length of output: 12280


🏁 Script executed:

#!/bin/bash
# Check _compute_word_for_day implementation for potential race conditions
echo "=== _compute_word_for_day function ==="
sed -n '/^def _compute_word_for_day/,/^def /p' webapp/app.py | head -n -1

echo -e "\n=== Check WORD_HISTORY_DIR initialization ==="
rg -n "WORD_HISTORY_DIR" webapp/app.py

Length of output: 1125


🏁 Script executed:

#!/bin/bash
# Check for any information disclosure in error messages
echo "=== All error return statements in API endpoints ==="
rg -n "return.*error|return.*404|return.*400|return.*500" webapp/app.py | grep -v "^#"

Length of output: 642


🏁 Script executed:

#!/bin/bash
# Check context around line 1383 to see which endpoint exposes exceptions
echo "=== Context around line 1383 ==="
sed -n '1365,1390p' webapp/app.py

echo -e "\n=== Check if word.html has any hreflang implementation ==="
rg -n "hreflang" webapp/templates/word.html -C3

Length of output: 920


🏁 Script executed:

#!/bin/bash
# Get the full generate_word_image function and where it's called
echo "=== Complete generate_word_image function ==="
sed -n '/^def generate_word_image/,/^def /p' webapp/app.py | head -n -1

echo -e "\n=== Where generate_word_image is called ==="
rg -n "generate_word_image\(" webapp/app.py -B5 -A5

Length of output: 2361


🏁 Script executed:

#!/bin/bash
# Check the word-image API endpoint that likely calls generate_word_image
echo "=== Word image API endpoint ==="
sed -n '/@app.route.*word-image/,/^@app.route\|^def [a-z_]*\(/p' webapp/app.py | head -80

Length of output: 81


🏁 Script executed:

#!/bin/bash
# Count total word pages to verify sitemap math
echo "=== Verify sitemap pagination math ==="
echo "Languages count:"
rg -n "language_codes\s*=" webapp/app.py | head -3

echo -e "\nEstimated days (assuming ~1700+ days):"
echo "If todays_idx = 1700, and n_langs = 65:"
echo "total_word_pages = 1700 * 65 = 110,500"
echo "n_word_sitemaps = ceil(110,500 / 50,000) = ceil(2.21) = 3"
echo "This matches the ~111K URLs mentioned in the PR description"

Length of output: 470


🏁 Script executed:

#!/bin/bash
# Get the complete word-image API endpoint to see how errors are handled
echo "=== Word image API endpoint (complete) ==="
sed -n '/^@app.route.*api\/word-image/,/^@app.route/p' webapp/app.py | head -n -1

echo -e "\n=== Alternative word-image route ==="
sed -n '/^@app.route.*word-image.*>/,/^@app.route/p' webapp/app.py | head -n -1

Length of output: 6498


🏁 Script executed:

#!/bin/bash
# Check if there's a way to set custom hreflang pattern per template
echo "=== Check word_page route for hreflang context ==="
sed -n '/^def word_page/,/^def /p' webapp/app.py | head -n -1

echo -e "\n=== Check if word_page passes any hreflang variables ==="
rg -n "hreflang" webapp/app.py -B3 -A3

Length of output: 2105


@Hugo0: I've completed a thorough review of the latest commits. Here are my findings across all five areas:


1. 🔴 SEO Correctness — Critical Issue Found

CRITICAL: Missing hreflang on Word Pages

Issue: The word.html template does not include the _hreflang.html partial, meaning all 111K+ word pages are missing hreflang tags.

<!-- index.html and game.html both include it -->
{% include 'partials/_hreflang.html' %}

<!-- word.html is missing this include -->

PROBLEM: Wrong hreflang URL Pattern

Even if you add the include, the global hreflang_url_pattern is wrong for word pages:

# Line 107 in app.py
"hreflang_url_pattern": "https://wordle.global/{lang}"

This generates URLs like https://wordle.global/de when word pages need https://wordle.global/de/word/1234.

Recommendation:

# In word_page route, override the pattern
`@app.route`("/<lang_code>/word/<int:day_idx>")
def word_page(lang_code, day_idx):
    # ... existing code ...
    
    return render_template(
        "word.html",
        # ... existing variables ...
        hreflang_url_pattern=f"https://wordle.global/{{lang}}/word/{day_idx}",
    )
<!-- In word.html, add after canonical tag (line 32) -->
<link rel="canonical" href="https://wordle.global/{{ lang_code }}/word/{{ day_idx }}">
{% include 'partials/_hreflang.html' %}

✅ What's Correct:

  • Canonical URLs properly implemented on all pages
  • OG meta tags complete with dynamic content
  • Twitter card meta tags correct
  • Sitemap structure correct

2. ⚠️ Word History Caching — Race Condition

Race Condition in Cache Writes

Lines 488-490 write cache files without file locking, creating a race condition when multiple requests compute the same word simultaneously.

# Lines 488-490 — NO LOCKING
os.makedirs(os.path.join(WORD_HISTORY_DIR, lang_code), exist_ok=True)
with open(cache_path, "w") as f:
    f.write(word)

Compare to the stats update which does use locking (lines 1082-1097).

No Error Handling for Corrupt Cache

Lines 479-480 read cache files with no try-except, so a corrupt/empty cache file would cause an exception.

Recommendation:

def get_word_for_day(lang_code, day_idx):
    """Get the daily word for a specific language and day index."""
    cache_path = os.path.join(WORD_HISTORY_DIR, lang_code, f"{day_idx}.txt")
    
    # Check cache first
    if os.path.exists(cache_path):
        try:
            with open(cache_path, "r") as f:
                cached = f.read().strip()
                if cached:  # Ensure non-empty
                    return cached
        except (OSError, IOError):
            pass  # Fall through to recompute

    word = _compute_word_for_day(lang_code, day_idx)

    # Cache past/current days (not future)
    todays_idx = get_todays_idx()
    if day_idx <= todays_idx:
        lang_dir = os.path.join(WORD_HISTORY_DIR, lang_code)
        os.makedirs(lang_dir, exist_ok=True)
        
        # Use file locking to prevent race conditions
        lock_path = cache_path + ".lock"
        try:
            with open(lock_path, "w") as lock_f:
                fcntl.flock(lock_f, fcntl.LOCK_EX | fcntl.LOCK_NB)
                # Write only if not already written by another process
                if not os.path.exists(cache_path):
                    with open(cache_path, "w") as f:
                        f.write(word)
        except (IOError, BlockingIOError):
            pass  # Another process is writing, skip
        finally:
            if os.path.exists(lock_path):
                try:
                    os.unlink(lock_path)
                except OSError:
                    pass

    return word

✅ What's Sound:

  • Cache-first approach is correct
  • Only caching past/current days (not future) is correct
  • Using exist_ok=True prevents directory race conditions

3. ✅ Async Definition Loading — Clean Implementation

The hybrid approach is well-designed:

Server-side rendering (when cached):
Lines 1510-1519 read cached definitions from disk and render immediately in the template, giving search engines fully-rendered content.

Client-side fallback (when not cached):
The JS fetch in word.html gracefully falls back to a Wiktionary link if the definition isn't cached yet.

SEO Impact: Minimal concern. Definitions get cached on the first visit (by the game completion flow or by search engine crawlers), so subsequent crawls see server-rendered content.

✅ No issues found in this area.


4. ✅ Sitemap Pagination — Math Correct

Verified the pagination logic:

Math checks out:

  • Total pages = todays_idx * n_langs, number of sitemaps = ceil(total_pages / 50,000)
  • 65 languages × ~1700 days = ~110,500 URLs
  • 50K per sitemap = 3 paginated files ✓

Priority gradient correct:
Priority formula: age_ratio = day_offset / max(todays_idx, 1); priority = max(0.3, 1.0 - age_ratio * 0.7)

  • Today's word: priority 1.0
  • Oldest word: priority 0.3 ✓

URL ordering correct:
Newest days first (higher priority), computed as d_idx = todays_idx - day_offset

Templates correct:
All three sitemap templates (sitemap_index.xml, sitemap_main.xml, sitemap_words.xml) are properly structured.

✅ No issues found in this area.


5. 🔴 Security — Information Disclosure

Exception Details Leaked in Image Generation

Issue: Line 1383 returns f"error: {e}" from generate_word_image(), which is called by the public API endpoint at line 1484.

This could expose:

  • Internal file paths (IOError: /data/word-images/...)
  • OpenAI API error details
  • Network error details

Recommendation:

# Line 1383 — replace with:
except (openai.OpenAIError, urlreq.URLError, IOError, OSError) as e:
    logging.error(f"Image generation failed for {word}: {e}")
    return "error"  # Generic, no details

Then in the endpoint (line 1484-1487):

result = generate_word_image(word, definition_hint, openai_key, cache_dir, cache_path)
if result == "ok":
    return send_from_directory(cache_dir, f"{word.lower()}.webp")
# Don't expose the error string
return "Image generation failed", 500

✅ What's Secure:

  • abort(404) properly used throughout
  • robots.txt and llms.txt endpoints appropriate
  • Generic error messages in other places don't leak info
  • Definition API returns safe JSON errors

Summary & Priority Recommendations

🔴 Critical (Must Fix):

  1. Add hreflang to word.html — Missing on all 111K+ word pages, major SEO issue
  2. Fix hreflang_url_pattern for word pages — Current pattern generates wrong URLs

🟡 High Priority (Should Fix):

  1. Add file locking to word history caching — Prevent race conditions and corrupt cache files
  2. Stop leaking exception details — Line 1383 exposes internal errors

🟢 The Rest is Solid:

  • Sitemap pagination is mathematically correct
  • Async definition loading is clean
  • Canonical URLs and OG tags are correct
  • Security posture is good aside from the exception leak

Would you like me to generate a code snippet combining all the fixes for the critical and high-priority issues?

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 9

🧹 Nitpick comments (13)
webapp/data/languages/br/language_config.json (1)

15-15: LGTM — minor trailing-space inconsistency worth verifying.

"Rannañ" is the correct Breton translation. However, every sibling key in the "text" block still carries a trailing space ("Next word ", "Copied to clipboard ", etc.), and the original "Share " did too. If the rendering layer concatenates these values directly without inserting its own space, dropping the trailing space here could produce a layout difference compared to other keys. Confirm this is intentional (ideally by stripping trailing spaces from all sibling entries in a follow-up cleanup).

🧹 Optional: strip trailing spaces from all sibling entries for consistency
-        "next_word": "Next word ",
-        "no_attempts": "You haven't tried any words yet! ",
-        "share": "Rannañ",
-        "notification-copied": "Copied to clipboard ",
-        "notification-partial-word": "Please enter a full word "
+        "next_word": "Next word",
+        "no_attempts": "You haven't tried any words yet!",
+        "share": "Rannañ",
+        "notification-copied": "Copied to clipboard",
+        "notification-partial-word": "Please enter a full word"

Apply the equivalent cleanup across other language config files in the same PR for consistency.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@webapp/data/languages/br/language_config.json` at line 15, The "share" entry
in the "text" block ("share": "Rannañ") no longer has the trailing space that
all sibling keys currently include (e.g., "Next word ", "Copied to clipboard ",
previous "Share "), so verify rendering expectations and make them consistent:
either restore the trailing space on "share" to match existing siblings or
better, remove trailing spaces from all entries in this "text" block (and
optionally across other language_config.json files) and update the rendering
layer to insert spaces as needed; locate the "share" key in the "text" object
and apply the chosen consistent cleanup across sibling keys.
webapp/data/languages/de/de_blocklist.txt (1)

42-43: Place new entries under appropriate section headers.

mbyte and polit are appended after the last categorized block without a section comment, breaking the established structure. mbyte fits under # English loanwords that feel out of place and polit under # Political organization acronyms (or a new section if a broader "political terms" category is intended).

♻️ Suggested reorganization
 # English loanwords that feel out of place
 story
 stack
 excel
 sparc
 photo
+mbyte

 # Offensive/inappropriate
 ...

 # Political organization acronyms
 nsdap
+polit
-
-mbyte
-polit
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@webapp/data/languages/de/de_blocklist.txt` around lines 42 - 43, Move the two
orphaned entries into their correct section headers: place "mbyte" under the
existing comment header "# English loanwords that feel out of place" and place
"polit" under "# Political organization acronyms" (or create a new "# Political
organization acronyms" section if it does not exist yet) so all blocklist
entries remain grouped by the established section comments and file structure.
webapp/data/languages/hu/hu_blocklist.txt (1)

1-2: Add a section comment explaining why izzam is blocked.

Unlike the German blocklist (which uses section headers like # Offensive/inappropriate), this file has no rationale for the entry. A brief inline comment aids future maintainers in understanding whether this is offensive, a proper noun, etc.

♻️ Suggested addition
 # Hungarian blocklist
+
+# Offensive/inappropriate
 izzam
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@webapp/data/languages/hu/hu_blocklist.txt` around lines 1 - 2, Add a short
inline comment above or beside the blocked token "izzam" in hu_blocklist.txt
explaining the rationale (e.g., "offensive term", "personal name / PII",
"spam-related", etc.) and follow the same style as the German file by using a
section header like "# Offensive/inappropriate" if appropriate; update the entry
so maintainers can see why "izzam" is blocked without guessing.
webapp/templates/sitemap_main.xml (1)

1-13: Consider adding <lastmod> to URL entries for sitemap consistency

sitemap_main.xml omits <lastmod> on all <url> entries while sitemap_index.xml supplies <lastmod>{{ lastmod }}</lastmod> on every <sitemap> entry. Omitting it is valid per the Sitemap protocol, but including it for the language root pages helps search engines prioritise re-crawling more accurately.

🔧 Optional addition
   <url>
     <loc>{{ base_url }}</loc>
+    <lastmod>{{ lastmod }}</lastmod>
     <priority>1.0</priority>
   </url>
   {% for language in languages %}
   <url>
     <loc>{{ base_url }}/{{ language }}</loc>
+    <lastmod>{{ lastmod }}</lastmod>
     <priority>0.9</priority>
   </url>
   {% endfor %}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@webapp/templates/sitemap_main.xml` around lines 1 - 13, Add a <lastmod>
element to each <url> entry in sitemap_main.xml by inserting <lastmod>{{ lastmod
}}</lastmod> under the <loc> for both the base URL (using {{ base_url }}) and
inside the loop for each language URL (the block iterating over {{ languages
}}), so the template emits a lastmod timestamp for the root and
language-specific URLs consistent with sitemap_index.xml.
webapp/templates/word.html (1)

177-177: word.upper() and lang_code are not tojson-encoded in the JS string.

lang_name_native was correctly fixed to use |tojson, but the other two Jinja2 values in the same string literal are still injected raw. Although Wordle words and language codes are currently safe, consistency protects against future edge cases.

✨ Suggested change
-            const text = 'Wordle ' + {{ lang_name_native|tojson }} + ' #{{ day_idx }} — {{ word.upper() }}\nhttps://wordle.global/{{ lang_code }}/word/{{ day_idx }}';
+            const text = 'Wordle ' + {{ lang_name_native|tojson }} + ' #{{ day_idx }} \u2014 ' + {{ word.upper()|tojson }} + '\nhttps://wordle.global/' + {{ lang_code|tojson }} + '/word/{{ day_idx }}';
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@webapp/templates/word.html` at line 177, The JS string building the share
text uses raw Jinja values for word.upper() and lang_code; update the template
to JSON-encode those interpolations like lang_name_native is encoded so the JS
receives safe JS strings—replace {{ word.upper() }} with {{ word.upper()|tojson
}} and {{ lang_code }} with {{ lang_code|tojson }} in the const text assignment
(the line that defines "const text" in the word.html template).
frontend/src/definitions.ts (1)

50-100: Two optional improvements for renderDefinitionCard.

  1. def.url (Wiktionary URL) is never rendered. The PR objective calls for a "Wiktionary link" in the definition card, and WordDefinition.url carries this URL, but the function only surfaces wordPageUrl (the word subpage). Consider also rendering def.url as a secondary "Wiktionary ↗" link alongside the word-page icon.

  2. RTL text direction not set. For Hebrew, Arabic, and Persian definitions the rendered text will be displayed LTR. The <p> element holding the definition could receive dir="rtl" when appropriate (e.g., passed via a dir parameter or inferred from lang). As per coding guidelines: "Consider right-to-left (RTL) language support when implementing UI features, particularly for Hebrew, Arabic, and Persian."

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/src/definitions.ts` around lines 50 - 100, renderDefinitionCard
currently only renders the optional wordPageUrl and never surfaces def.url (the
Wiktionary link) and doesn't set RTL direction for RTL languages; update
renderDefinitionCard to (1) render def.url as a secondary link (label like
"Wiktionary ↗") next to the existing linkHtml when def.url is present and safe
(use the existing isSafeUrl and escapeHtml) and (2) set dir="rtl" on the <p>
that contains the definition when a passed-in signal indicates RTL (e.g., add an
optional dir or rtl boolean parameter to renderDefinitionCard and apply it when
building container.innerHTML), referencing the function name
renderDefinitionCard, the def.url property, wordPageUrl/linkHtml construction,
and the container.innerHTML template so reviewers can locate the changes.
webapp/templates/index.html (1)

335-341: Sound & Haptics toggle is missing a description line, unlike every other toggle.

Dark Mode, and previously the separate Haptic/Sound toggles, all include a text-sm description span. The consolidated toggle omits it, leaving users without context about what the toggle controls.

✨ Suggested addition
             <div class="flex flex-col">
                 <span class="font-medium">{{ ui.sound_and_haptics or "Sound & Haptics" }}</span>
+                <span class="text-sm text-neutral-500 dark:text-neutral-400">{{ ui.sound_and_haptics_desc or "Sound effects and vibration" }}</span>
             </div>
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@webapp/templates/index.html` around lines 335 - 341, Add a missing
description line for the "Sound & Haptics" toggle by inserting a small
descriptive span under the label (matching other toggles' pattern) and use an
existing UI key if available (e.g., ui.sound_and_haptics_description) or a
sensible fallback like "Enable sound and vibration feedback". Update the block
that renders the label and toggle (references: the span with class "font-medium"
and the call to toggle_switch('feedbackEnabled', 'toggleFeedback()', ...)) so it
includes a second span with class "text-sm" (and appropriate muted/text color
classes) directly under the label span to match the other toggles' layout and
styling.
webapp/templates/game.html (2)

383-409: Tab panels lack role="tabpanel" and aria-labelledby.

The tab buttons correctly have role="tab" and :aria-selected, but the corresponding content divs (lines 412, 449, 512) are missing role="tabpanel" and aria-labelledby / id pairings. This is a minor a11y gap — screen readers won't associate panels with their tabs.

♿ Suggested additions
-                    <div v-show="statsTab === 'today'">
+                    <div v-show="statsTab === 'today'" role="tabpanel" id="tabpanel-today" aria-labelledby="tab-today">

-                    <div v-show="statsTab === 'stats'">
+                    <div v-show="statsTab === 'stats'" role="tabpanel" id="tabpanel-stats" aria-labelledby="tab-stats">

-                    <div v-show="statsTab === 'global'">
+                    <div v-show="statsTab === 'global'" role="tabpanel" id="tabpanel-global" aria-labelledby="tab-global">

And add matching id attributes on the tab buttons (e.g., id="tab-today").

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@webapp/templates/game.html` around lines 383 - 409, The tab panels are
missing accessible associations: add id attributes to each tab button (e.g.,
id="tab-today", id="tab-stats", id="tab-global" on the buttons that set
statsTab) and add role="tabpanel" plus aria-labelledby pointing to the matching
button id on each corresponding content div that renders the today, stats and
global panels (the containers tied to statsTab values). Ensure each panel also
has :aria-hidden or similar visibility handling consistent with statsTab so only
the active panel is exposed to assistive tech.

429-438: Duplicate share button markup across Today and Stats tabs.

The share button block (class, :class, v-on:click, inner spans) is copied verbatim. Consider extracting it into a Jinja partial or macro to keep the template DRY.

Also applies to: 499-508

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@webapp/templates/game.html` around lines 429 - 438, The share button markup
is duplicated in the template (same block around the Today and Stats tabs);
extract it into a reusable Jinja partial or macro (e.g., create a macro
render_share_button that uses shareButtonState, shareResults,
language.config.text.copied and language.config.text.share) and replace both
duplicated blocks with a call to that macro so the markup and bindings
(v-on:click="shareResults", :class computed from shareButtonState, inner spans
referencing language.config.text.*) are maintained in one place.
webapp/app.py (3)

1256-1304: Sitemap pagination math is correct but uses inconsistent 404 pattern.

Line 1269 returns "Not found", 404 as a plain string, while other routes use abort(404) which renders the 404 template. Consider using abort(404) for consistency.

♻️ Suggested fix
     if offset >= total:
-        return "Not found", 404
+        abort(404)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@webapp/app.py` around lines 1256 - 1304, In sitemap_words(), replace the
plain return "Not found", 404 with abort(404) so the route uses the same 404
rendering flow as other handlers; ensure the Flask abort symbol is imported
(from flask import abort) if not already present in the module so the change
compiles cleanly.

1382-1383: generate_word_image returns internal error details in its string.

Line 1383 returns f"error: {e}" which may include stack trace or internal details (e.g., API key fragments in certain OpenAI errors). The caller at line 1487 only checks result == "ok" and returns a generic 500, so the error string isn't exposed to the client. However, if logging is added to the caller later, this could leak. Consider logging the exception inside generate_word_image and returning a simple sentinel.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@webapp/app.py` around lines 1382 - 1383, generate_word_image currently
returns f"error: {e}" exposing internal exception details; instead catch the
exception, log the full exception (using the existing logger) inside
generate_word_image and return a simple sentinel (e.g. "error" or None) so
callers that only compare to "ok" (the caller that checks result == "ok") don't
receive internal details; update generate_word_image to use the logger to record
the exception and change the return value to a non-detailed sentinel.

936-971: Silent except Exception: pass on network errors hinders debugging.

_fetch_native_wiktionary (line 969), fetch_definition_cached (lines 990, 1021), and _fetch_english_definition (line 1056) all silently swallow exceptions. While definitions are optional, having zero visibility into fetch failures makes it hard to diagnose issues like Wiktionary API changes or DNS problems. Consider logging at debug level at minimum.

♻️ Example for _fetch_native_wiktionary
-        except Exception:
-            pass
+        except Exception:
+            logging.debug("Native Wiktionary fetch failed for %s/%s", lang_code, try_word, exc_info=True)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@webapp/app.py` around lines 936 - 971, Replace the silent "except Exception:
pass" blocks in _fetch_native_wiktionary, fetch_definition_cached, and
_fetch_english_definition with an explicit exception handler that captures the
exception (e) and logs it at debug level (including exc_info/stacktrace) using
the module's existing logger (e.g., app.logger or logger) before continuing;
specifically change each bare except to "except Exception as e:
logger.debug('<contextual message> while fetching definition for %s (%s)',
word_or_key, lang_code, exc_info=True)" so failures are visible without changing
control flow.
frontend/src/game.ts (1)

1185-1231: Inconsistent localStorage parsing between loadFeedbackPreference and loadWordInfoPreference.

feedbackEnabled uses strict stored === 'true' (line 1189), while wordInfoEnabled uses stored !== 'false' (line 1226). Both default to true, but the feedbackEnabled approach means any unexpected stored value (e.g., '1') would set it to false, losing the intended default. Consider using the same pattern for both — stored !== 'false' is safer for default-ON preferences.

♻️ Suggested fix
             loadFeedbackPreference(): void {
                 try {
                     const stored = localStorage.getItem('feedbackEnabled');
                     if (stored !== null) {
-                        this.feedbackEnabled = stored === 'true';
+                        this.feedbackEnabled = stored !== 'false';
                     } else {
                         this.feedbackEnabled = true;
                     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/src/game.ts` around lines 1185 - 1231, The parsing for feedback
preference in loadFeedbackPreference is stricter than loadWordInfoPreference;
change the logic in loadFeedbackPreference (and any related write/read in
toggleFeedback) to mirror the safe default-on pattern used in
loadWordInfoPreference by treating any stored value other than the explicit
string 'false' as enabled (i.e., set this.feedbackEnabled = stored !== 'false'),
keep calling setHapticsEnabled/setSoundEnabled and saving via localStorage in
toggleFeedback as-is, and ensure analytics.trackSettingsChange still reports the
updated this.feedbackEnabled value.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@webapp/app.py`:
- Around line 471-492: The cache read in get_word_for_day currently returns an
empty string when the cached file contains only whitespace; change the logic to
treat a stripped-empty cache as invalid: after reading with f.read().strip(), if
the value is falsy (""), fall through to call _compute_word_for_day(lang_code,
day_idx) and proceed to cache that computed value (using the same os.makedirs +
open(cache_path, "w") flow when day_idx <= get_todays_idx()). Ensure you still
return a non-empty word and that a corrupt cache is overwritten with the
recomputed value.
- Around line 1520-1523: Replace the local duplicate mapping by using the
existing module-level _WIKT_LANG_MAP: remove the local wikt_lang_map definition
and change the lookup to use _WIKT_LANG_MAP.get(lang_code, lang_code) so
wikt_lang is derived from the shared map; ensure the code references
_WIKT_LANG_MAP and the variable names wikt_lang and lang_code remain unchanged.
- Around line 1464-1490: The current pending-file logic can block image
generation if the process dies before the finally removes pending_path; update
the block around pending_path creation/check to treat .pending files older than
a threshold (e.g., 5 minutes) as stale and remove them before returning 202.
Concretely: import time at the top, when seeing os.path.exists(pending_path) or
catching FileExistsError use os.path.getmtime(pending_path) and time.time() to
compare age and os.unlink stale files, then proceed to create the pending marker
and call generate_word_image/send_from_directory as before; ensure cleanup in
finally still runs.

In `@webapp/data/languages/en/en_5words_supplement.txt`:
- Line 10639: Remove the sensitive word "slave" from the answer pool in the
en_5words_supplement.txt word list (the entry "slave" shown in the diff), or
move it to a separate banned/filtered list used by the seeding/rotation logic so
it will not be selected as an answer; update any loader or validation that
builds the active answer set to exclude words from that banned list (ensure the
word "slave" is referenced there) and add a short test/assertion to confirm
banned words are not present in the generated daily answer set.

In `@webapp/data/languages/hyw/language_config.json`:
- Line 15: The "share" text entry in the hyw language JSON ("share") is missing
the trailing space used by other entries in the same "text" block; edit the
"share" value to include a trailing space ("Կիսուիլ ") to match the file's
spacing convention and flag the string for linguistic review—confirm with a
Western Armenian speaker whether the infinitive "Կիսուիլ" is the correct form
for a button label or if an imperative/noun (e.g., "Կիսե") should be used;
update the "share" value accordingly once the correct form is confirmed.

In `@webapp/data/languages/ltg/language_config.json`:
- Line 15: The "share" value in language_config.json is currently
"Daleit"—please verify this Latgalian translation against a reliable source
(native Latgalian speaker, authoritative dictionary, or Wiktionary entry) and
update the "share" key value if incorrect; reference the JSON key "share" and
confirm casing/diacritics match native orthography before committing the
corrected string.

In `@webapp/templates/index.html`:
- Around line 34-47: The JSON-LD block embeds the template variable description
raw, which can break the JSON if it contains quotes; change the description
interpolation in the WebApplication JSON-LD (the "{{ description }}" entry) to
use the tojson filter (i.e., render description | tojson) so it is properly
JSON-escaped within the <script type="application/ld+json"> block and matches
how languages are handled with "languages.keys() | list | tojson".

In `@webapp/templates/word.html`:
- Around line 5-34: The head section of the word.html template is missing the
hreflang alternate links include; add {% include 'partials/_hreflang.html' %}
inside the <head> (near the existing {% include 'partials/_base_head.html' %})
so language-specific rel="alternate" links render for the word pages, and update
the backend to set hreflang_url_pattern to "https://wordle.global/{lang}/word/{{
day_idx }}" so the partial can generate correct URLs for the Wordle page
(referencing the word.html template and the hreflang_url_pattern variable used
by the _hreflang.html partial).
- Around line 112-119: The totals are never updated because Jinja2 {% set %}
inside the {% for %} is block-scoped; change to use a namespace object (e.g., ns
= namespace(total_attempts=0, total_wins=0)) and update ns.total_attempts and
ns.total_wins inside the loop so the values persist after the loop, then compute
the average using ns.total_attempts and ns.total_wins. Also guard the max call
on word_stats.distribution.values() by checking if the distribution is non-empty
(e.g., only call values() | max when word_stats.distribution.values()
exists/length > 0) to avoid ValueError when the distribution is empty.

---

Duplicate comments:
In `@webapp/templates/word.html`:
- Around line 40-45: Replace hardcoded English strings in the word.html template
(e.g. "Play Wordle", "Community Stats", "Players", "Win Rate", "Avg Attempts",
"Share", "Play Today's Wordle") with lookups into the config.ui mapping with
sensible fallbacks, matching the pattern used in the game template; for example
change occurrences where the UI text is literal to use {{ config.ui.<key> or
'<fallback English>' }} (use consistent keys like play_wordle, community_stats,
players, win_rate, avg_attempts, share, play_today) so translations follow the
existing config.ui convention while preserving current English when keys are
missing.
- Around line 194-218: The inline fetch handler builds card.innerHTML using
unescaped data.part_of_speech and data.definition (and the templated {{ word
}}), creating an XSS risk; add a small escapeHtml helper in this script (or use
textContent/createElement) and apply it to data.part_of_speech and
data.definition (and the word output) before concatenating into card.innerHTML,
or instead build the DOM with createElement/appendChild to set textContent for
the parts; update the code that sets card.innerHTML in the fetch.then callback
(referencing card, fallback, data.part_of_speech, data.definition, and {{ word
}}) to use the escaped/safer values.

---

Nitpick comments:
In `@frontend/src/definitions.ts`:
- Around line 50-100: renderDefinitionCard currently only renders the optional
wordPageUrl and never surfaces def.url (the Wiktionary link) and doesn't set RTL
direction for RTL languages; update renderDefinitionCard to (1) render def.url
as a secondary link (label like "Wiktionary ↗") next to the existing linkHtml
when def.url is present and safe (use the existing isSafeUrl and escapeHtml) and
(2) set dir="rtl" on the <p> that contains the definition when a passed-in
signal indicates RTL (e.g., add an optional dir or rtl boolean parameter to
renderDefinitionCard and apply it when building container.innerHTML),
referencing the function name renderDefinitionCard, the def.url property,
wordPageUrl/linkHtml construction, and the container.innerHTML template so
reviewers can locate the changes.

In `@frontend/src/game.ts`:
- Around line 1185-1231: The parsing for feedback preference in
loadFeedbackPreference is stricter than loadWordInfoPreference; change the logic
in loadFeedbackPreference (and any related write/read in toggleFeedback) to
mirror the safe default-on pattern used in loadWordInfoPreference by treating
any stored value other than the explicit string 'false' as enabled (i.e., set
this.feedbackEnabled = stored !== 'false'), keep calling
setHapticsEnabled/setSoundEnabled and saving via localStorage in toggleFeedback
as-is, and ensure analytics.trackSettingsChange still reports the updated
this.feedbackEnabled value.

In `@webapp/app.py`:
- Around line 1256-1304: In sitemap_words(), replace the plain return "Not
found", 404 with abort(404) so the route uses the same 404 rendering flow as
other handlers; ensure the Flask abort symbol is imported (from flask import
abort) if not already present in the module so the change compiles cleanly.
- Around line 1382-1383: generate_word_image currently returns f"error: {e}"
exposing internal exception details; instead catch the exception, log the full
exception (using the existing logger) inside generate_word_image and return a
simple sentinel (e.g. "error" or None) so callers that only compare to "ok" (the
caller that checks result == "ok") don't receive internal details; update
generate_word_image to use the logger to record the exception and change the
return value to a non-detailed sentinel.
- Around line 936-971: Replace the silent "except Exception: pass" blocks in
_fetch_native_wiktionary, fetch_definition_cached, and _fetch_english_definition
with an explicit exception handler that captures the exception (e) and logs it
at debug level (including exc_info/stacktrace) using the module's existing
logger (e.g., app.logger or logger) before continuing; specifically change each
bare except to "except Exception as e: logger.debug('<contextual message> while
fetching definition for %s (%s)', word_or_key, lang_code, exc_info=True)" so
failures are visible without changing control flow.

In `@webapp/data/languages/br/language_config.json`:
- Line 15: The "share" entry in the "text" block ("share": "Rannañ") no longer
has the trailing space that all sibling keys currently include (e.g., "Next word
", "Copied to clipboard ", previous "Share "), so verify rendering expectations
and make them consistent: either restore the trailing space on "share" to match
existing siblings or better, remove trailing spaces from all entries in this
"text" block (and optionally across other language_config.json files) and update
the rendering layer to insert spaces as needed; locate the "share" key in the
"text" object and apply the chosen consistent cleanup across sibling keys.

In `@webapp/data/languages/de/de_blocklist.txt`:
- Around line 42-43: Move the two orphaned entries into their correct section
headers: place "mbyte" under the existing comment header "# English loanwords
that feel out of place" and place "polit" under "# Political organization
acronyms" (or create a new "# Political organization acronyms" section if it
does not exist yet) so all blocklist entries remain grouped by the established
section comments and file structure.

In `@webapp/data/languages/hu/hu_blocklist.txt`:
- Around line 1-2: Add a short inline comment above or beside the blocked token
"izzam" in hu_blocklist.txt explaining the rationale (e.g., "offensive term",
"personal name / PII", "spam-related", etc.) and follow the same style as the
German file by using a section header like "# Offensive/inappropriate" if
appropriate; update the entry so maintainers can see why "izzam" is blocked
without guessing.

In `@webapp/templates/game.html`:
- Around line 383-409: The tab panels are missing accessible associations: add
id attributes to each tab button (e.g., id="tab-today", id="tab-stats",
id="tab-global" on the buttons that set statsTab) and add role="tabpanel" plus
aria-labelledby pointing to the matching button id on each corresponding content
div that renders the today, stats and global panels (the containers tied to
statsTab values). Ensure each panel also has :aria-hidden or similar visibility
handling consistent with statsTab so only the active panel is exposed to
assistive tech.
- Around line 429-438: The share button markup is duplicated in the template
(same block around the Today and Stats tabs); extract it into a reusable Jinja
partial or macro (e.g., create a macro render_share_button that uses
shareButtonState, shareResults, language.config.text.copied and
language.config.text.share) and replace both duplicated blocks with a call to
that macro so the markup and bindings (v-on:click="shareResults", :class
computed from shareButtonState, inner spans referencing language.config.text.*)
are maintained in one place.

In `@webapp/templates/index.html`:
- Around line 335-341: Add a missing description line for the "Sound & Haptics"
toggle by inserting a small descriptive span under the label (matching other
toggles' pattern) and use an existing UI key if available (e.g.,
ui.sound_and_haptics_description) or a sensible fallback like "Enable sound and
vibration feedback". Update the block that renders the label and toggle
(references: the span with class "font-medium" and the call to
toggle_switch('feedbackEnabled', 'toggleFeedback()', ...)) so it includes a
second span with class "text-sm" (and appropriate muted/text color classes)
directly under the label span to match the other toggles' layout and styling.

In `@webapp/templates/sitemap_main.xml`:
- Around line 1-13: Add a <lastmod> element to each <url> entry in
sitemap_main.xml by inserting <lastmod>{{ lastmod }}</lastmod> under the <loc>
for both the base URL (using {{ base_url }}) and inside the loop for each
language URL (the block iterating over {{ languages }}), so the template emits a
lastmod timestamp for the root and language-specific URLs consistent with
sitemap_index.xml.

In `@webapp/templates/word.html`:
- Line 177: The JS string building the share text uses raw Jinja values for
word.upper() and lang_code; update the template to JSON-encode those
interpolations like lang_name_native is encoded so the JS receives safe JS
strings—replace {{ word.upper() }} with {{ word.upper()|tojson }} and {{
lang_code }} with {{ lang_code|tojson }} in the const text assignment (the line
that defines "const text" in the word.html template).

Comment thread webapp/app.py
Comment on lines +471 to +492
def get_word_for_day(lang_code, day_idx):
"""Get the daily word for a specific language and day index.

Once a word is computed for a past day, it's cached to disk so future
word list changes can never alter historical daily words.
"""
# Check cache first
cache_path = os.path.join(WORD_HISTORY_DIR, lang_code, f"{day_idx}.txt")
if os.path.exists(cache_path):
with open(cache_path, "r") as f:
return f.read().strip()

word = _compute_word_for_day(lang_code, day_idx)

# Cache past/current days (not future)
todays_idx = get_todays_idx()
if day_idx <= todays_idx:
os.makedirs(os.path.join(WORD_HISTORY_DIR, lang_code), exist_ok=True)
with open(cache_path, "w") as f:
f.write(word)

return word
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

get_word_for_day returns empty string on corrupt cache without fallback.

If the cached file exists but is empty or contains whitespace only, f.read().strip() returns "", and that empty string is used as the daily word. Add a validity check so corrupted cache falls through to recomputation.

🛡️ Proposed fix
     cache_path = os.path.join(WORD_HISTORY_DIR, lang_code, f"{day_idx}.txt")
     if os.path.exists(cache_path):
-        with open(cache_path, "r") as f:
-            return f.read().strip()
+        try:
+            with open(cache_path, "r") as f:
+                cached = f.read().strip()
+            if cached:
+                return cached
+        except IOError:
+            pass
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@webapp/app.py` around lines 471 - 492, The cache read in get_word_for_day
currently returns an empty string when the cached file contains only whitespace;
change the logic to treat a stripped-empty cache as invalid: after reading with
f.read().strip(), if the value is falsy (""), fall through to call
_compute_word_for_day(lang_code, day_idx) and proceed to cache that computed
value (using the same os.makedirs + open(cache_path, "w") flow when day_idx <=
get_todays_idx()). Ensure you still return a non-empty word and that a corrupt
cache is overwritten with the recomputed value.

Comment thread webapp/app.py
Comment on lines +1464 to +1490
# Skip if another request is already generating this image
pending_path = cache_path + ".pending"
if os.path.exists(pending_path):
return "Image being generated", 202

# Mark as pending to prevent duplicate DALL-E calls
os.makedirs(cache_dir, exist_ok=True)
try:
open(pending_path, "x").close()
except FileExistsError:
return "Image being generated", 202

try:
# Use English definition for DALL-E prompt (DALL-E understands English best)
definition_hint = ""
en_def = _fetch_english_definition(word, lang_code)
if en_def:
definition_hint = f", which means {en_def}"

# Generate image via DALL-E
result = generate_word_image(word, definition_hint, openai_key, cache_dir, cache_path)
if result == "ok":
return send_from_directory(cache_dir, f"{word.lower()}.webp")
return "Image generation failed", 500
finally:
if os.path.exists(pending_path):
os.unlink(pending_path)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Stale .pending file can permanently block image generation.

If the worker is killed (OOM, SIGKILL) after creating the pending file (line 1472) but before the finally block runs, the .pending file persists and all subsequent requests for that word return 202 indefinitely. Consider adding an age check (e.g., delete pending files older than 5 minutes).

🛡️ Proposed fix
     pending_path = cache_path + ".pending"
-    if os.path.exists(pending_path):
-        return "Image being generated", 202
+    if os.path.exists(pending_path):
+        # Clean up stale pending files (e.g., from crashed workers)
+        try:
+            age = time.time() - os.path.getmtime(pending_path)
+            if age < 300:  # 5 minutes
+                return "Image being generated", 202
+            os.unlink(pending_path)
+        except OSError:
+            pass

(Requires import time at the top of the file.)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@webapp/app.py` around lines 1464 - 1490, The current pending-file logic can
block image generation if the process dies before the finally removes
pending_path; update the block around pending_path creation/check to treat
.pending files older than a threshold (e.g., 5 minutes) as stale and remove them
before returning 202. Concretely: import time at the top, when seeing
os.path.exists(pending_path) or catching FileExistsError use
os.path.getmtime(pending_path) and time.time() to compare age and os.unlink
stale files, then proceed to create the pending marker and call
generate_word_image/send_from_directory as before; ensure cleanup in finally
still runs.

Comment thread webapp/app.py
Comment on lines +1520 to +1523

# Map language code to Wiktionary subdomain
wikt_lang_map = {"nb": "no", "nn": "no", "hyw": "hy", "ckb": "ku"}
wikt_lang = wikt_lang_map.get(lang_code, lang_code)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major

Duplicate wikt_lang_map definition — use the module-level _WIKT_LANG_MAP.

Line 1522 redefines the same mapping that already exists at line 780 as _WIKT_LANG_MAP.

♻️ Proposed fix
     # Map language code to Wiktionary subdomain
-    wikt_lang_map = {"nb": "no", "nn": "no", "hyw": "hy", "ckb": "ku"}
-    wikt_lang = wikt_lang_map.get(lang_code, lang_code)
+    wikt_lang = _WIKT_LANG_MAP.get(lang_code, lang_code)
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
# Map language code to Wiktionary subdomain
wikt_lang_map = {"nb": "no", "nn": "no", "hyw": "hy", "ckb": "ku"}
wikt_lang = wikt_lang_map.get(lang_code, lang_code)
# Map language code to Wiktionary subdomain
wikt_lang = _WIKT_LANG_MAP.get(lang_code, lang_code)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@webapp/app.py` around lines 1520 - 1523, Replace the local duplicate mapping
by using the existing module-level _WIKT_LANG_MAP: remove the local
wikt_lang_map definition and change the lookup to use
_WIKT_LANG_MAP.get(lang_code, lang_code) so wikt_lang is derived from the shared
map; ensure the code references _WIKT_LANG_MAP and the variable names wikt_lang
and lang_code remain unchanged.

zygon
zymes
zymic
slave
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Consider excluding "slave" from the answer pool.

The word is a valid dictionary entry and will be shuffled into the answer rotation via seed 42. However, "slave" carries deep historical trauma and has been explicitly removed from major word games (including NYT Wordle) precisely to avoid surfacing it as a daily answer. If the project has a content policy for sensitive words, this word likely falls under it.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@webapp/data/languages/en/en_5words_supplement.txt` at line 10639, Remove the
sensitive word "slave" from the answer pool in the en_5words_supplement.txt word
list (the entry "slave" shown in the diff), or move it to a separate
banned/filtered list used by the seeding/rotation logic so it will not be
selected as an answer; update any loader or validation that builds the active
answer set to exclude words from that banned list (ensure the word "slave" is
referenced there) and add a short test/assertion to confirm banned words are not
present in the generated daily answer set.

"next_word": "Next word ",
"no_attempts": "You haven't tried any words yet! ",
"share": "Share ",
"share": "Կիսուիլ",
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

cat -n webapp/data/languages/hyw/language_config.json

Repository: Hugo0/wordle

Length of output: 1794


🏁 Script executed:

# Check if other language files exist and their "share" entries
find webapp/data/languages -name "language_config.json" -type f | head -5 | xargs -I {} sh -c 'echo "=== {} ===" && grep -A 2 -B 2 "share" {}'

Repository: Hugo0/wordle

Length of output: 1551


🏁 Script executed:

# Check English and more languages to see the pattern
grep -A 5 '"text"' webapp/data/languages/en/language_config.json | grep -A 6 '"text"'

Repository: Hugo0/wordle

Length of output: 278


🏁 Script executed:

# Check Bulgarian more thoroughly - does it consistently omit trailing spaces?
grep -A 5 '"text"' webapp/data/languages/bg/language_config.json

Repository: Hugo0/wordle

Length of output: 283


🏁 Script executed:

# Check more language files to understand the pattern - are there languages that consistently omit trailing spaces?
for lang in en ar az bg br ca cs cy da de el es fa fr gu he hi hu id it ja ka ko la pt ro ru sk sv ta te tr uk; do
  file="webapp/data/languages/$lang/language_config.json"
  if [ -f "$file" ]; then
    share_line=$(grep '"share"' "$file")
    echo "$lang: $share_line"
  fi
done

Repository: Hugo0/wordle

Length of output: 909


🏁 Script executed:

# Let's check git history to see if this is a new file or modification
git log --oneline --all -- webapp/data/languages/hyw/language_config.json 2>/dev/null | head -5 || echo "No git history available"

# Also check if the file has other translations beyond the text block
grep -E '"(subheader|share)"' webapp/data/languages/hyw/language_config.json

Repository: Hugo0/wordle

Length of output: 260


Trailing space inconsistent within this file's text block.

In the hyw language file, the "share" value differs from all other entries in the "text" block: "next_word", "no_attempts", "notification-copied", and "notification-partial-word" all retain trailing spaces, while "share": "Կիսուիլ" does not. This local inconsistency may cause UI spacing issues if the rendering code depends on that convention for this language.

Consider adding a trailing space: "share": "Կիսուիլ " to match the pattern in this file, or confirm that the omission is intentional.

Additionally, the infinitive form "Կիսուիլ" should be verified with a Western Armenian speaker—depending on UI context, an imperative form (e.g., "Կիսե") or noun form might be more natural as a button label.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@webapp/data/languages/hyw/language_config.json` at line 15, The "share" text
entry in the hyw language JSON ("share") is missing the trailing space used by
other entries in the same "text" block; edit the "share" value to include a
trailing space ("Կիսուիլ ") to match the file's spacing convention and flag the
string for linguistic review—confirm with a Western Armenian speaker whether the
infinitive "Կիսուիլ" is the correct form for a button label or if an
imperative/noun (e.g., "Կիսե") should be used; update the "share" value
accordingly once the correct form is confirmed.

"next_word": "Next word ",
"no_attempts": "You haven't tried any words yet! ",
"share": "Share ",
"share": "Daleit",
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

find . -name "app.py" -type f | head -5

Repository: Hugo0/wordle

Length of output: 71


🏁 Script executed:

rg -n "wiktionary|language_code" --type py -B 2 -A 2 | head -100

Repository: Hugo0/wordle

Length of output: 6156


🏁 Script executed:

cat webapp/data/languages/ltg/language_config.json

Repository: Hugo0/wordle

Length of output: 1533


🏁 Script executed:

rg -n "wiktionary|definition|look_up" --type py -B 3 -A 3

Repository: Hugo0/wordle

Length of output: 15950


🏁 Script executed:

rg -n "language.*code" webapp/app.py | grep -i "wikt\|65\|mapping" -B 3 -A 3

Repository: Hugo0/wordle

Length of output: 780


🏁 Script executed:

grep -n "ltg\|Wiktionary\|language_code_iso\|language_code_3" webapp/app.py -B 2 -A 2

Repository: Hugo0/wordle

Length of output: 1880


🏁 Script executed:

rg -n "_WIKT_LANG_MAP" webapp/app.py -B 5 -A 50

Repository: Hugo0/wordle

Length of output: 4868


Verify the Latgalian translation of "share".

The trailing-space cleanup is correct. The language code fields remain empty (pre-existing), which is acceptable—the code gracefully falls back to English Wiktionary for definitions if a native Latgalian Wiktionary is unavailable.

Confirm "Daleit" as the correct Latgalian word for "share" (plausible given the Latvian root dalīties, but worth verifying with a native speaker or dictionary).

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@webapp/data/languages/ltg/language_config.json` at line 15, The "share" value
in language_config.json is currently "Daleit"—please verify this Latgalian
translation against a reliable source (native Latgalian speaker, authoritative
dictionary, or Wiktionary entry) and update the "share" key value if incorrect;
reference the JSON key "share" and confirm casing/diacritics match native
orthography before committing the corrected string.

Comment on lines +34 to +47
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "WebApplication",
"name": "Wordle Global",
"url": "https://wordle.global",
"description": "{{ description }}",
"applicationCategory": "GameApplication",
"operatingSystem": "Any",
"offers": { "@type": "Offer", "price": "0", "priceCurrency": "USD" },
"inLanguage": {{ languages.keys() | list | tojson }},
"browserRequirements": "Requires JavaScript"
}
</script>
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

JSON-LD description value is not JSON-escaped.

Line 40 embeds {{ description }} inside a manually quoted JSON string. If the description ever contains a " character (e.g., from a language name expansion), the JSON-LD block becomes invalid and search engines silently ignore it. In contrast, line 44 correctly uses | tojson. Apply the same pattern:

🔍 Suggested fix
-        "description": "{{ description }}",
+        "description": {{ description | tojson }},
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "WebApplication",
"name": "Wordle Global",
"url": "https://wordle.global",
"description": "{{ description }}",
"applicationCategory": "GameApplication",
"operatingSystem": "Any",
"offers": { "@type": "Offer", "price": "0", "priceCurrency": "USD" },
"inLanguage": {{ languages.keys() | list | tojson }},
"browserRequirements": "Requires JavaScript"
}
</script>
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "WebApplication",
"name": "Wordle Global",
"url": "https://wordle.global",
"description": {{ description | tojson }},
"applicationCategory": "GameApplication",
"operatingSystem": "Any",
"offers": { "@type": "Offer", "price": "0", "priceCurrency": "USD" },
"inLanguage": {{ languages.keys() | list | tojson }},
"browserRequirements": "Requires JavaScript"
}
</script>
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@webapp/templates/index.html` around lines 34 - 47, The JSON-LD block embeds
the template variable description raw, which can break the JSON if it contains
quotes; change the description interpolation in the WebApplication JSON-LD (the
"{{ description }}" entry) to use the tojson filter (i.e., render description |
tojson) so it is properly JSON-escaped within the <script
type="application/ld+json"> block and matches how languages are handled with
"languages.keys() | list | tojson".

Comment on lines +5 to +34
<head>
{% include 'partials/_base_head.html' %}

{# Page-specific SEO meta tags #}
{% set title = "Wordle " ~ lang_name_native ~ " #" ~ day_idx ~ " — " ~ word.upper() %}
{% set def_text = definition.definition if definition and definition.definition else "" %}
{% set pos_text = definition.part_of_speech ~ ": " if definition and definition.part_of_speech else "" %}
{% if def_text %}
{% set description = "The Wordle word for " ~ lang_name ~ " #" ~ day_idx ~ " (" ~ word_date.strftime('%B %d, %Y') ~ ") was " ~ word.upper() ~ ". " ~ pos_text ~ def_text %}
{% else %}
{% set description = "The Wordle word for " ~ lang_name ~ " #" ~ day_idx ~ " (" ~ word_date.strftime('%B %d, %Y') ~ ") was " ~ word.upper() ~ ". Can you guess it in 6 tries?" %}
{% endif %}
<title>{{ title }}</title>
<meta name="description" content="{{ description[:200] }}">
<meta property="og:title" content="{{ title }}">
<meta property="og:url" content="https://wordle.global/{{ lang_code }}/word/{{ day_idx }}">
<meta property="og:type" content="article">
<meta property="og:description" content="{{ description[:200] }}">
{# Word-specific AI art as social preview (generates on first crawl if not cached) #}
<meta property="og:image" content="https://wordle.global/{{ lang_code }}/api/word-image/{{ word }}?day_idx={{ day_idx }}">
<meta property="twitter:image" content="https://wordle.global/{{ lang_code }}/api/word-image/{{ word }}?day_idx={{ day_idx }}">
<meta name="twitter:card" content="summary_large_image">
<meta property="twitter:domain" content="wordle.global">
<meta property="twitter:url" content="https://wordle.global/{{ lang_code }}/word/{{ day_idx }}">
<meta property="twitter:title" content="{{ title }}">
<meta property="twitter:description" content="{{ description[:200] }}">

<link rel="canonical" href="https://wordle.global/{{ lang_code }}/word/{{ day_idx }}">
<link rel="preconnect" href="https://www.googletagmanager.com" crossorigin>
</head>
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Missing {% include 'partials/_hreflang.html' %} in <head>.

Word pages are per-language and are the primary targets for the new sitemap/SEO work, yet they lack the hreflang alternate links. The backend also needs to set hreflang_url_pattern to https://wordle.global/{lang}/word/{{ day_idx }} for these pages.

🔍 Suggested addition
     <link rel="canonical" href="https://wordle.global/{{ lang_code }}/word/{{ day_idx }}">
+    {% include 'partials/_hreflang.html' %}
     <link rel="preconnect" href="https://www.googletagmanager.com" crossorigin>
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
<head>
{% include 'partials/_base_head.html' %}
{# Page-specific SEO meta tags #}
{% set title = "Wordle " ~ lang_name_native ~ " #" ~ day_idx ~ " — " ~ word.upper() %}
{% set def_text = definition.definition if definition and definition.definition else "" %}
{% set pos_text = definition.part_of_speech ~ ": " if definition and definition.part_of_speech else "" %}
{% if def_text %}
{% set description = "The Wordle word for " ~ lang_name ~ " #" ~ day_idx ~ " (" ~ word_date.strftime('%B %d, %Y') ~ ") was " ~ word.upper() ~ ". " ~ pos_text ~ def_text %}
{% else %}
{% set description = "The Wordle word for " ~ lang_name ~ " #" ~ day_idx ~ " (" ~ word_date.strftime('%B %d, %Y') ~ ") was " ~ word.upper() ~ ". Can you guess it in 6 tries?" %}
{% endif %}
<title>{{ title }}</title>
<meta name="description" content="{{ description[:200] }}">
<meta property="og:title" content="{{ title }}">
<meta property="og:url" content="https://wordle.global/{{ lang_code }}/word/{{ day_idx }}">
<meta property="og:type" content="article">
<meta property="og:description" content="{{ description[:200] }}">
{# Word-specific AI art as social preview (generates on first crawl if not cached) #}
<meta property="og:image" content="https://wordle.global/{{ lang_code }}/api/word-image/{{ word }}?day_idx={{ day_idx }}">
<meta property="twitter:image" content="https://wordle.global/{{ lang_code }}/api/word-image/{{ word }}?day_idx={{ day_idx }}">
<meta name="twitter:card" content="summary_large_image">
<meta property="twitter:domain" content="wordle.global">
<meta property="twitter:url" content="https://wordle.global/{{ lang_code }}/word/{{ day_idx }}">
<meta property="twitter:title" content="{{ title }}">
<meta property="twitter:description" content="{{ description[:200] }}">
<link rel="canonical" href="https://wordle.global/{{ lang_code }}/word/{{ day_idx }}">
<link rel="preconnect" href="https://www.googletagmanager.com" crossorigin>
</head>
<head>
{% include 'partials/_base_head.html' %}
{# Page-specific SEO meta tags #}
{% set title = "Wordle " ~ lang_name_native ~ " #" ~ day_idx ~ " — " ~ word.upper() %}
{% set def_text = definition.definition if definition and definition.definition else "" %}
{% set pos_text = definition.part_of_speech ~ ": " if definition and definition.part_of_speech else "" %}
{% if def_text %}
{% set description = "The Wordle word for " ~ lang_name ~ " #" ~ day_idx ~ " (" ~ word_date.strftime('%B %d, %Y') ~ ") was " ~ word.upper() ~ ". " ~ pos_text ~ def_text %}
{% else %}
{% set description = "The Wordle word for " ~ lang_name ~ " #" ~ day_idx ~ " (" ~ word_date.strftime('%B %d, %Y') ~ ") was " ~ word.upper() ~ ". Can you guess it in 6 tries?" %}
{% endif %}
<title>{{ title }}</title>
<meta name="description" content="{{ description[:200] }}">
<meta property="og:title" content="{{ title }}">
<meta property="og:url" content="https://wordle.global/{{ lang_code }}/word/{{ day_idx }}">
<meta property="og:type" content="article">
<meta property="og:description" content="{{ description[:200] }}">
{# Word-specific AI art as social preview (generates on first crawl if not cached) #}
<meta property="og:image" content="https://wordle.global/{{ lang_code }}/api/word-image/{{ word }}?day_idx={{ day_idx }}">
<meta property="twitter:image" content="https://wordle.global/{{ lang_code }}/api/word-image/{{ word }}?day_idx={{ day_idx }}">
<meta name="twitter:card" content="summary_large_image">
<meta property="twitter:domain" content="wordle.global">
<meta property="twitter:url" content="https://wordle.global/{{ lang_code }}/word/{{ day_idx }}">
<meta property="twitter:title" content="{{ title }}">
<meta property="twitter:description" content="{{ description[:200] }}">
<link rel="canonical" href="https://wordle.global/{{ lang_code }}/word/{{ day_idx }}">
{% include 'partials/_hreflang.html' %}
<link rel="preconnect" href="https://www.googletagmanager.com" crossorigin>
</head>
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@webapp/templates/word.html` around lines 5 - 34, The head section of the
word.html template is missing the hreflang alternate links include; add {%
include 'partials/_hreflang.html' %} inside the <head> (near the existing {%
include 'partials/_base_head.html' %}) so language-specific rel="alternate"
links render for the word pages, and update the backend to set
hreflang_url_pattern to "https://wordle.global/{lang}/word/{{ day_idx }}" so the
partial can generate correct URLs for the Wordle page (referencing the word.html
template and the hreflang_url_pattern variable used by the _hreflang.html
partial).

Comment on lines +112 to +119
{% set total_attempts = 0 %}
{% set total_wins = 0 %}
{% for k, v in word_stats.distribution.items() %}
{% set total_attempts = total_attempts + (k|int * v) %}
{% set total_wins = total_wins + v %}
{% endfor %}
<p class="text-lg font-bold">{{ (total_attempts / total_wins) | round(1) if total_wins > 0 else "—" }}</p>
<p class="text-[10px] text-neutral-500 dark:text-neutral-400">Avg Attempts</p>
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

{% set %} inside a {% for %} loop does not update the outer-scope variable — Avg Attempts always renders as "—".

In Jinja2, variable assignments inside a for block are scoped to that block. After the loop total_attempts and total_wins remain 0, so the if total_wins > 0 guard is always false. Use namespace():

🐛 Proposed fix using Jinja2 namespace
-                    {% set total_attempts = 0 %}
-                    {% set total_wins = 0 %}
-                    {% for k, v in word_stats.distribution.items() %}
-                        {% set total_attempts = total_attempts + (k|int * v) %}
-                        {% set total_wins = total_wins + v %}
-                    {% endfor %}
-                    <p class="text-lg font-bold">{{ (total_attempts / total_wins) | round(1) if total_wins > 0 else "—" }}</p>
+                    {% set ns = namespace(total_attempts=0, total_wins=0) %}
+                    {% for k, v in word_stats.distribution.items() %}
+                        {% set ns.total_attempts = ns.total_attempts + (k|int * v) %}
+                        {% set ns.total_wins = ns.total_wins + v %}
+                    {% endfor %}
+                    <p class="text-lg font-bold">{{ (ns.total_attempts / ns.total_wins) | round(1) if ns.total_wins > 0 else "—" }}</p>

Additionally, line 124 calls word_stats.distribution.values() | max which raises ValueError on an empty distribution (e.g., all players lost). Add a guard:

-                {% set max_count = word_stats.distribution.values() | max %}
+                {% set max_count = (word_stats.distribution.values() | max) if word_stats.distribution else 0 %}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@webapp/templates/word.html` around lines 112 - 119, The totals are never
updated because Jinja2 {% set %} inside the {% for %} is block-scoped; change to
use a namespace object (e.g., ns = namespace(total_attempts=0, total_wins=0))
and update ns.total_attempts and ns.total_wins inside the loop so the values
persist after the loop, then compute the average using ns.total_attempts and
ns.total_wins. Also guard the max call on word_stats.distribution.values() by
checking if the distribution is non-empty (e.g., only call values() | max when
word_stats.distribution.values() exists/length > 0) to avoid ValueError when the
distribution is empty.

- Add hreflang to word.html with correct URL pattern per word page
- Harden word history cache: try/except on read, atomic writes via os.replace
- Stop leaking exception details in image generation (log + generic error)
- Replace raw HTML /stats endpoint with proper stats page showing language
  word counts, community play data, and word count bar chart
- Stats data cached in memory (5 min TTL) to avoid scanning disk on each hit
- Remove old status_list.txt dependency
- Add /stats to sitemap
@Hugo0
Copy link
Copy Markdown
Owner Author

Hugo0 commented Feb 22, 2026

Thanks for the thorough review @coderabbitai! All 4 findings addressed in 1b704db:

1. 🔴 Missing hreflang on word pages — Fixed. Added {% include 'partials/_hreflang.html' %} to word.html and pass hreflang_url_pattern with the correct /{lang}/word/{day_idx} pattern from the route.

2. ⚠️ Word history cache race condition — Fixed. Added try/except on cache reads (handles corrupt/empty files gracefully) and switched to atomic writes via temp file + os.replace() which is atomic on POSIX.

3. ⚠️ Exception details leaked in image generation — Fixed. Now logs the error server-side and returns a generic "error" string. The endpoint already returned "Image generation failed", 500 so no details leak to clients.

4. ✅ Async definitions + sitemap — Confirmed correct, no changes needed.

Also in this commit: replaced the old raw-HTML /stats endpoint with a proper stats page showing language word counts, community play data, and a bar chart. Stats aggregation is cached in memory with 5-min TTL.

Use max-w-lg container, same card styles, bar chart, and label sizes
as word.html. Replace table with compact list view.
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Feb 22, 2026

Tip

For best results, initiate chat on the files or code changes.

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Verify fix 1: hreflang on word pages
echo "=== Verify word.html includes hreflang partial ==="
grep -n "_hreflang" webapp/templates/word.html

echo -e "\n=== Verify word_page route passes hreflang_url_pattern ==="
sed -n '/^def word_page/,/^def /p' webapp/app.py | grep -A20 "render_template" | head -25

echo -e "\n=== Verify fix 2: Atomic writes in get_word_for_day ==="
sed -n '/^def get_word_for_day/,/^def /p' webapp/app.py | head -n -1

echo -e "\n=== Verify fix 3: Generic error in generate_word_image ==="
sed -n '/^def generate_word_image/,/^def /p' webapp/app.py | grep -A5 "except" | head -15

echo -e "\n=== Check new stats page implementation ==="
rg -n "@app.route.*\/stats" webapp/app.py -A30 | head -50

Length of output: 2362


🏁 Script executed:

#!/bin/bash
# Check the new stats page implementation
echo "=== Stats page route ==="
grep -n "route.*stats" webapp/app.py

echo -e "\n=== Stats page implementation details ==="
sed -n '/@app.route.*stats/,/^@app.route\|^def [a-z_]/p' webapp/app.py | head -80

echo -e "\n=== Check for stats template ==="
fd -t f "stats.html" webapp/templates/

echo -e "\n=== Check for stats caching implementation ==="
rg -n "_stats_cache|stats.*cache|TTL" webapp/app.py | head -20

Length of output: 814


@Hugo0: Excellent work! 🎉 All fixes are confirmed and properly implemented:

✅ Verified Fixes

1. 🔴 hreflang on word pages — Confirmed!

  • word.html line 33 includes the partial
  • Route passes the correct pattern: f"https://wordle.global/{{lang}}/word/{day_idx}"
  • All 111K+ word pages now have proper hreflang tags

2. ⚠️ Word history cache race condition — Excellent implementation!

# Graceful read with try/except
try:
    with open(cache_path, "r") as f:
        cached = f.read().strip()
        if cached:
            return cached
except OSError:
    pass  # Fall through to recompute

# Atomic write via temp file
tmp_path = cache_path + ".tmp"
with open(tmp_path, "w") as f:
    f.write(word)
os.replace(tmp_path, cache_path)  # Atomic on POSIX ✓

3. ⚠️ Exception leak — Perfect fix!

except (openai.OpenAIError, urlreq.URLError, IOError, OSError) as e:
    logging.error(f"Image generation failed for {word}: {e}")
    return "error"  # Generic, no details leaked

4. 🆕 Stats page — Nice addition!

  • Clean in-memory caching with 5-minute TTL
  • Aggregates language word counts and community play data
  • Simple and effective implementation

All critical SEO issues and security concerns are now resolved. The implementation quality is high — atomic writes, specific exception handling, and proper error recovery. Great job! 🚀


If you found this review helpful, would you consider giving us a shout-out on X?

Thank you for using CodeRabbit!

- Reuse fetch_definition_cached for DALL-E hints (deduplicate logic)
- Add 'definitions' to SettingsChangeParams type union
- Stats bar chart: fixed-width monospace lang codes (w-10 font-mono),
  more spacing (space-y-1.5), taller bars (h-3.5)
- .env quote stripping and unused lang_name already fixed in prior commits
- pregenerate_images.py urlretrieve already fixed (uses app.generate_word_image)
- Add 'definitions' to SettingsChangeParams type union
- Reuse fetch_definition_cached for DALL-E image hints (dedup)
- Stats bar chart: monospace fixed-width lang codes, more spacing
- Add "Since {date}" disclaimer on community stats section
@Hugo0
Copy link
Copy Markdown
Owner Author

Hugo0 commented Feb 22, 2026

@coderabbitai Please review the new /stats page implementation (webapp/templates/stats.html, webapp/app.py stats route) and the latest fixes addressing your previous review comments.

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Feb 22, 2026

Tip

For best results, initiate chat on the files or code changes.

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Read the stats page template
echo "=== stats.html template ==="
cat webapp/templates/stats.html

echo -e "\n=== Stats route in app.py ==="
rg -n "@app.route.*\/stats" webapp/app.py -A80 | head -100

echo -e "\n=== Stats caching implementation ==="
rg -n "_stats_cache|STATS_CACHE" webapp/app.py -B2 -A5

echo -e "\n=== Verify previous fixes are in place ==="
echo "1. Check hreflang in word.html:"
grep -n "_hreflang" webapp/templates/word.html

echo -e "\n2. Check atomic write in get_word_for_day:"
sed -n '/^def get_word_for_day/,/^def /p' webapp/app.py | grep -A3 "replace\|tmp"

echo -e "\n3. Check generic error in generate_word_image:"
sed -n '/^def generate_word_image/,/^def /p' webapp/app.py | grep -B2 -A2 "return \"error\""

Length of output: 9245


🏁 Script executed:

#!/bin/bash
# Get the full stats route with a simpler pattern
echo "=== Complete stats route ==="
sed -n '/^@app.route.*stats/,/^@app.route\|^def [a-z_]/p' webapp/app.py | head -n -1

echo -e "\n=== _build_stats_data function ==="
sed -n '/^def _build_stats_data/,/^def /p' webapp/app.py | head -n -1

echo -e "\n=== Check for XSS risks in template rendering ==="
echo "Checking if language names are escaped in stats.html:"
grep -n "{{ lang.name" webapp/templates/stats.html

echo -e "\n=== Check error handling in stats aggregation ==="
rg -n "try:|except" webapp/app.py | grep -A5 -B5 "stats\|_build_stats"

Length of output: 3490


🏁 Script executed:

#!/bin/bash
# Get the stats route more carefully
echo "=== Stats route (lines 1259-1270) ==="
sed -n '1259,1270p' webapp/app.py

echo -e "\n=== Check how stats data is passed to template ==="
rg -n "render_template.*stats" webapp/app.py -B2 -A2

echo -e "\n=== Check for any security issues in file reading ==="
echo "Looking at line 1213 where stats files are read:"
sed -n '1205,1225p' webapp/app.py

echo -e "\n=== Verify Jinja auto-escaping is enabled ==="
rg -n "autoescape" webapp/app.py

echo -e "\n=== Check if there are any raw/safe filters in stats.html ==="
grep -n "| safe\||raw" webapp/templates/stats.html

Length of output: 1771


@Hugo0: I've completed a thorough review of the new /stats page implementation and verified all previous fixes. Here are my findings:


✅ Previous Fixes Confirmed

All four critical/high-priority issues from the last review are properly fixed:

  1. ✅ hreflang on word pages — Line 33 of word.html includes the partial
  2. ✅ Atomic word history writes — Uses tmp file + os.replace() for atomic writes
  3. ✅ Generic error in image generation — Returns "error" instead of exception details
  4. ✅ hreflang URL pattern — Word route passes correct pattern with {lang}/word/{day_idx}

📊 Stats Page Implementation Review

✅ Strong Points

1. Clean Architecture

# Line 1259-1263: Simple route with cached data
`@app.route`("/stats")
def stats():
    data = _build_stats_data()
    return render_template("stats.html", **data)
  • Clear separation: aggregation logic in _build_stats_data(), presentation in template
  • Proper caching strategy prevents expensive re-aggregation on every request

2. Effective Caching

# Lines 1175-1185: In-memory cache with TTL
_stats_cache = {"data": None, "ts": 0}
_STATS_CACHE_TTL = 300  # 5 minutes

if _stats_cache["data"] and now - _stats_cache["ts"] < _STATS_CACHE_TTL:
    return _stats_cache["data"]
  • 5-minute TTL balances freshness vs performance
  • Simple and effective for read-heavy workload

3. SEO-Friendly Template

  • Proper meta tags with dynamic content
  • Canonical URL included
  • hreflang partial included (good for multi-language site)
  • Semantic HTML structure

4. Security — No XSS Risks

  • Jinja auto-escaping is enabled by default (confirmed: no autoescape=False config)
  • No | safe or | raw filters in template
  • All user-facing data (language names, counts) properly escaped

⚠️ Minor Issues to Consider

1. Silent Error Swallowing (Again)

# Lines 1213-1221: Same pattern as before
try:
    with open(os.path.join(lang_dir, fname), "r") as f:
        s = json.load(f)
        # ...
except Exception:
    pass  # No logging

Impact: If stats files are corrupt or have unexpected format, you won't know. Makes debugging production issues difficult.

Recommendation:

except (json.JSONDecodeError, OSError) as e:
    logging.warning(f"Failed to load stats file {fname} for {lc}: {e}")
    pass

2. Unsafe Filename to Integer Conversion

# Line 1218: Assumes fname is valid before int()
day = int(fname.replace(".json", ""))

Risk: If a non-numeric file (e.g., .DS_Store, backup.json.bak) exists in the stats directory, this raises ValueError and kills the entire stats aggregation.

Recommendation:

try:
    day = int(fname.replace(".json", ""))
    if earliest_stats_idx is None or day < earliest_stats_idx:
        earliest_stats_idx = day
except ValueError:
    continue  # Skip non-numeric filenames

3. No Rate Limiting on Stats Page

Not critical since the page is cached, but a malicious actor could still force cache invalidation every 5 minutes by timing requests. Consider adding light rate limiting:

from flask_limiter import Limiter

`@app.route`("/stats")
`@limiter.limit`("30 per minute")  # Generous limit
def stats():
    ...

(Same Flask-Limiter recommendation from first review — defer to separate PR is fine.)


📈 Template Review (stats.html)

Strong points:

  • Responsive grid layout with Tailwind
  • Dark mode support matches site-wide pattern
  • Accessible markup (semantic HTML, proper heading hierarchy)
  • Clean data visualization (bar chart using percentage widths)
  • Good UX: clickable language codes/names, tabular numbers, hover states

Minor suggestions:

  1. Language list is unsorted by name — Currently sorted by word count (descending), which is good for the chart but less discoverable for users looking for a specific language. Consider a toggle or secondary sort option.

  2. Win rate column could be confusing — Shows per-language win rate but only when global_plays > 0. Consider adding a tooltip or note explaining this is aggregated from recent community data (since date X).


Summary & Recommendations

🟢 Implementation Quality: Strong

The stats page is well-implemented with:

  • Clean architecture and proper caching
  • SEO-friendly markup
  • No security vulnerabilities
  • Good UX and visual design

🟡 Recommended Improvements (Non-Critical):

Priority order:

  1. Add logging to exception handler (lines 1221) — aids debugging
  2. Validate filename before int() conversion (line 1218) — prevents crashes
  3. Consider adding light rate limiting — prevents cache-invalidation abuse (defer to later PR is fine)

✅ All Previous Critical Fixes Verified

The implementation is production-ready. The minor issues are defensive improvements rather than bugs. Great work on the stats page — it provides valuable transparency and is well-executed! 🎉

Would you like me to generate a code snippet combining the two logging/validation fixes?

- Catch json.JSONDecodeError/OSError specifically, log warnings
- Skip non-numeric filenames safely (ValueError guard on int parse)
@Hugo0 Hugo0 merged commit f6c56b7 into main Feb 22, 2026
4 checks passed
Hugo0 added a commit that referenced this pull request Feb 22, 2026
- Fix XSS: escape all Wiktionary API data before innerHTML interpolation
- Fix TS strict: optional chaining on mock.calls[0]
- Fix .env parser: strip surrounding quotes from values
- Fix unbounded memory: clear _stats_seen_ips daily instead of growing forever
- Fix concurrent stats writes: file locking around read-modify-write cycle
- Fix urlretrieve timeout: use urlopen with 30s timeout for DALL-E downloads
- Fix broad exception: catch specific OpenAI/network errors in image generation
- Fix toggleDefinitions: reload definition when re-enabled after game completion
- Fix unhandled promise: add .catch() to fetchDefinition promise chain
- Fix JS injection: use tojson filter for lang_name_native in word.html script
- Increase IMAGE_LANGUAGES from top 10 to top 30
Hugo0 added a commit that referenced this pull request Feb 22, 2026
feat: Word definitions after game completion
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Feature request: add definition and explanation of the submitted word

1 participant