Open Graph link previews for shared audio lessons#637
Merged
Conversation
Shared-lesson links (zeeguu.org/shared-lesson/<uuid>) previously showed the generic site preview on WhatsApp/iMessage/Slack/etc., because the SPA shell has no per-lesson meta tags and crawlers don't run JS. This serves crawlers a server-rendered preview with the actual lesson. - DailyAudioLesson.cefr_level(): the lesson's CEFR level, read from its segments (most common, since cached word-audio can disagree — see #636). Surfaced in all three lesson responses incl. the shared view. - og_image.py: renders a 1200x630 branded card (amber elephant, language, title, duration/CEFR/type chips, words) from a lesson view dict. Cached on disk; lessons are immutable. Montserrat (brand font) + logo bundled as assets. - Endpoints: GET /shared_lesson_preview/<uuid> (OG/Twitter HTML) and GET /shared_lesson_image/<uuid>.png (the card). Both public, keyed on the unguessable share_uuid; no auth. nginx routing for crawler user-agents is staged separately in the ops tree. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Review follow-ups: - The logo + Montserrat fonts are generic brand assets, not audio-specific. Promote them from core/audio_lessons/assets to a top-level zeeguu/assets/ (images/ + fonts/) so the article-card follow-up can share them. og_image anchors paths on the package root. - Drop og_image's hardcoded language code→name dict; it duplicated Language.LANGUAGE_NAMES. The shared view now carries language_name (from lesson.language.name), so og_image stays DB-decoupled and mapping-free. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
mircealungu
added a commit
that referenced
this pull request
May 27, 2026
Mirrors the audio-lesson previews (#637/#638) for articles shared as zeeguu.org/read/article?id=N. New public, no-auth endpoints in article.py: - GET /shared_article_preview/<id> → server-rendered OG/Twitter HTML - GET /shared_article_image/<id>.png → a 1200x630 card The card features the article's own photo full-bleed under a scrim, with the title, source, reading time (word_count/200), and CEFR level — falling back to the branded cream card when an article has no image (rendered by og_image.render_article_card). A transient image-fetch failure serves the fallback without caching it, so the photo card appears once the fetch succeeds. nginx routing for /read/article is staged separately in the ops repo. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.

What & why
Sharing an audio lesson (
zeeguu.org/shared-lesson/<uuid>) showed the generic site preview on WhatsApp/iMessage/Slack/Facebook — every link looked identical ("Zeeguu · Learn languages while reading what you 🧡 like"). The SPA shell has no per-lesson meta tags and social crawlers don't run JS, so they never see the lesson.This serves crawlers a server-rendered preview with the real lesson: its title, language, duration, CEFR level, and a branded card image.
Sample output:
Changes
DailyAudioLesson.cefr_level()— the lesson's CEFR level, derived from its segments (most-common, since cached word-audio can carry mixed levels — see Word-lesson audio cache ignores CEFR level → mixed-level word lessons #636). Added to all three lesson responses (owner, shared, history).og_image.py— renders the 1200×630 card (transparent amber elephant, language pill, title, ▶ duration / CEFR / type chips, words footer) from a lesson view dict. Pure function, no DB coupling; rendered once and cached to<DATA>/og-images/shared-lessons/{id}.png. Bundles Montserrat (the brand font, OFL) + the elephant logo as package assets.audio_lessons.py):GET /shared_lesson_preview/<uuid>→ HTML with og:/twitter: tags, canonical → the app page, meta-refresh fallback.GET /shared_lesson_image/<uuid>.png→ the card.share_uuid(same as the existing shared JSON endpoint); no auth.Out of scope / follow-ups
opstree (amapof scraper user-agents + a/shared-lesson/location that proxies only bots to/shared_lesson_preview; humans get the SPA unchanged). Must be deployed +nginx -t && reloadfor previews to appear in the wild./read/article?id=) can get the same treatment cheaply — not in this PR.Testing notes
Unit-verified: card rendering + caching, OG text, route matching, compiles. Not run end-to-end locally — local DB is missing the
share_uuidcolumn (migration drift), soget_shared_lesson_view()can't be exercised here. Smoke-test on deploy:🤖 Generated with Claude Code