Skip to content

Open Graph link previews for shared audio lessons#637

Merged
mircealungu merged 2 commits into
masterfrom
shared-lesson-link-previews
May 27, 2026
Merged

Open Graph link previews for shared audio lessons#637
mircealungu merged 2 commits into
masterfrom
shared-lesson-link-previews

Conversation

@mircealungu
Copy link
Copy Markdown
Member

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.

before after
generic "Zeeguu" preview on every share lesson title + rich description + 1200×630 card

Sample output:

Topic: At the doctor's office — German audio lesson
A 4-min German audio lesson at level B1. Listen to a real conversation and pick up the words. With Zeeguu.

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.
  • Endpoints (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.
    • Both public, keyed on the unguessable share_uuid (same as the existing shared JSON endpoint); no auth.

Out of scope / follow-ups

  • nginx crawler routing is staged separately in the ops tree (a map of 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 && reload for previews to appear in the wild.
  • Article shares (/read/article?id=) can get the same treatment cheaply — not in this PR.
  • Mixed word-lesson CEFR levels: Word-lesson audio cache ignores CEFR level → mixed-level word lessons #636.

Testing notes

Unit-verified: card rendering + caching, OG text, route matching, compiles. Not run end-to-end locally — local DB is missing the share_uuid column (migration drift), so get_shared_lesson_view() can't be exercised here. Smoke-test on deploy:

curl -A "WhatsApp/2.0" https://api.zeeguu.org/shared_lesson_preview/<UUID>
curl -o /tmp/card.png https://api.zeeguu.org/shared_lesson_image/<UUID>.png && open /tmp/card.png

🤖 Generated with Claude Code

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>
@github-actions
Copy link
Copy Markdown

github-actions Bot commented May 27, 2026

ArchLens detected architectural changes in the following views:
diff

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 mircealungu merged commit c849bad into master May 27, 2026
3 checks passed
@mircealungu mircealungu deleted the shared-lesson-link-previews branch May 27, 2026 08:16
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>
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.

1 participant