Issue #7599 follow-up from @stffen: the OG description has no obvious settings.json knob and the i18n catalog default is English, but most preview crawlers (WhatsApp, Signal, Slack, Telegram, Facebook) don't send Accept-Language and so always hit the English fallback regardless of how many locale files translate `pad.social.description`. Keep the i18n catalog as the default source — translatable strings belong in locale files, per the original Qodo review on PR #7635 — but add an explicit `socialMeta.description` setting that wins when set as a non-empty string, regardless of negotiated language. This is the lever that fixes the crawler case for non-English instances without re-introducing per-language config in settings.json (operators who want that still use customLocaleStrings). - Empty/whitespace overrides are treated as unset (would otherwise silently blank the preview). - Override is HTML-escaped via the same path as every other value. - og:locale stays language-negotiated; only the description is forced. - Documented next to publicURL in settings.json.template and settings.json.docker (env var SOCIAL_META_DESCRIPTION). The customLocaleStrings example now spells out pad.social.description so operators discover both routes. 5 new unit specs + 4 new integration specs cover override-wins, null/missing fallback, blank-treated-as-unset, and HTML-escaping. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
9.1 KiB
Open Graph metadata for pad pages — Design
GitHub issue: https://github.com/ether/etherpad/issues/7599
Problem
When an Etherpad pad URL is shared in chat apps (WhatsApp, Signal, Slack, Discord, iMessage, etc.) the link unfurls with no preview because the rendered HTML carries no Open Graph or Twitter Card metadata. The reporter asks for basic OG tags so shared links show a meaningful preview.
Goals
- Pad URLs (
/p/:pad), timeslider URLs (/p/:pad/timeslider), and the homepage (/) emit Open Graph + Twitter Card meta tags. - A site operator can override the default description via
settings.json. - No new runtime dependencies. Implementation lives in the existing EJS templates and the existing settings module.
Non-goals
- Per-pad descriptions, custom OG images per pad, or pulling content from the pad body. The pad text is mutable and frequently empty at first load; using it would be both expensive (extra DB read on a hot path) and misleading.
- A plugin hook for OG override. Defer until a plugin actually needs it (YAGNI).
- Removing or changing the existing
<meta name="robots" content="noindex, nofollow">tag. OG unfurling is performed by chat clients that ignorerobots, so the privacy posture is unchanged.
Tags emitted
For the pad page (/p/:pad):
| Tag | Value |
|---|---|
og:title |
`{decoded pad name} |
og:description |
settings.socialDescription |
og:image |
absolute URL to {req.protocol}://{host}/favicon.ico* |
og:url |
absolute URL of the request |
og:type |
website |
og:site_name |
settings.title |
og:locale |
negotiated renderLang (already computed in pad.html), normalized to BCP-47 with underscore (e.g. en_US, de_DE); falls back to en_US |
og:image:alt |
"{settings.title} logo" (a11y — screen readers in chat clients announce this) |
twitter:card |
summary |
twitter:title |
same as og:title |
twitter:description |
same as og:description |
twitter:image |
same as og:image |
twitter:image:alt |
same as og:image:alt |
* settings.favicon is normally null (defaults route to the bundled
favicon.ico via the favicon middleware). The template builds the absolute
URL by joining req.protocol, req.get('host'), and the favicon path. If
settings.favicon is an absolute URL it is used verbatim.
For the timeslider (/p/:pad/timeslider): same tags, with og:title set
to {decoded pad name} (history) | {settings.title}.
For the homepage (/): same tags, with og:title set to
settings.title and og:url set to the request URL.
i18n source
The description text lives in Etherpad's standard locale catalog under the
key pad.social.description. The shipped English default in
src/locales/en.json is the softer rewording of the wording in the issue:
A collaborative document that everyone can edit in real time.
Other locale files may translate the key as the translation community picks
it up; missing translations fall back to English. No new settings.json
key is added — operators who want to override the text per-language do so
via the existing customLocaleStrings mechanism that Etherpad already
supports.
Locale negotiation. Resolution order at request time:
locales[renderLang]['pad.social.description'](exact match, whererenderLangwas negotiated viareq.acceptsLanguages()).locales[primarySubtag]['pad.social.description'](e.g.de-AT→de).locales.en['pad.social.description'](English fallback).- Empty string (only if
en.jsonis missing the key — should not happen in core).
The i18n hook now exports the loaded locales map so other server-side
modules can look up translated strings without re-reading the JSON files.
Implementation outline
- Settings — declare
socialDescription: stringon the Settings module with the default above; document it in both example settings files. - Helper — extract the meta-tag block into a single source of truth.
Preferred form is an EJS partial included from each template; if
Etherpad's
eejswrapper does not supportinclude()cleanly, fall back to a small JS helper (e.g.src/node/utils/socialMeta.ts) exported into the template via the existingeejs.requirecontext, returning the rendered<meta>block as a string. Implementation step 1 of the plan must verify which mechanismeejssupports before committing to one. - pad.html / timeslider.html / index.html — compute the four template
inputs at the top of each file and
<%- include('_socialMeta', {...}) %>in<head>, after the existing<title>line. The pad name is decoded withdecodeURIComponent(req.params.pad)and HTML-escaped via the existing<%= %>mechanism (EJS escapes by default). - Route handlers —
specialpages.tsalready passesreqandsettingsto the templates; no route changes needed.
Tests
Add to the existing backend test suite (likely
src/tests/backend/specs/specialpages.ts or a new
src/tests/backend/specs/socialmeta.ts):
- GET
/p/TestPad-7599→ response HTML contains<meta property="og:title" content="TestPad-7599 | Etherpad">and anog:descriptionmatching the default. - GET
/p/TestPad-7599withsettings.socialDescriptionoverridden to"Custom desc"→ that custom value appears inog:description. - GET
/p/Has%20Space→og:titlecontainsHas Space(decoded) and is HTML-safe (no raw%). - GET
/p/<script>(encoded) →og:titlecontains escaped<script>, not raw HTML. - GET
/p/TestPad/timeslider→og:titlecontains(history). - GET
/→og:titleequalssettings.title. - GET
/p/TestPadwithAccept-Language: deandsocialDescription: {default: "X", de: "Y"}→og:descriptionisYandog:localeisde_DE(orde). - Response includes
og:image:altandtwitter:image:alt.
The XSS escape test is the security-relevant one: pad IDs are user-controlled
(anyone can navigate to /p/<anything>).
Risks and trade-offs
- Pad-name leakage. Anyone the link is shared with can already see the pad
name in the URL, so emitting it in
og:titledoes not expose anything new. - Caching. OG tags are read once per unfurl. Chat clients cache aggressively;
changing
socialDescriptionwill not propagate to previously-cached previews. This is acceptable and standard. - Template-set drift. Etherpad has three top-level HTML templates that
need OG tags; the
_socialMetapartial avoids three copies of the same block.
Out of scope (future work)
- A
padSocialMetadatahook that lets plugins override the values. - Per-pad description (e.g. ep_pad_title integration).
- Generated preview images (would require a rendering service).
Follow-up (2026-05-07): operator description override
Issue #7599 follow-up comment from @stffen flagged two gaps in the shipped behaviour:
- The default description is in English and there is no obvious place in
settings.jsonto change it. - The visitor's language is negotiated from
Accept-Language, which most link-preview crawlers (WhatsApp, Signal, Slack, Telegram, Facebook) do not send — so non-English instances always serve the English fallback to crawlers regardless of which locale files exist.
Resolution: keep the i18n catalog as the default source (the original Qodo
review still stands — translatable strings belong in locale files), but add
an explicit settings.socialMeta.description override that wins when set:
socialMeta.description: null(default) → existing behaviour: i18n catalog withAccept-Languagenegotiation, English fallback.socialMeta.description: "<text>"→ that string is used verbatim forog:description/twitter:descriptionregardless of the negotiated language. This is the lever that fixes the crawler-no-Accept-Language case.- Empty / whitespace-only override is treated as unset (would otherwise blank out previews silently — a footgun).
- The override is HTML-escaped via the same path as every other interpolated value.
og:localeis unaffected; it continues to reflect the negotiated render language. Operators who want fully localised descriptions still usecustomLocaleStringsto overridepad.social.descriptionper-language.
Documentation lives next to publicURL in both settings.json.template
and settings.json.docker (mirrors how the original feature is
configured), and the customLocaleStrings example now shows the
pad.social.description key explicitly so operators can find both routes.