etherpad-lite/docs/superpowers/specs/2026-04-30-issue-7599-open-graph-metadata-design.md
John McLear 90aafb115e
feat(socialMeta): settings.socialMeta.description override (#7599 follow-up) (#7691)
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>
2026-05-07 18:12:23 +08:00

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 ignore robots, 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:

  1. locales[renderLang]['pad.social.description'] (exact match, where renderLang was negotiated via req.acceptsLanguages()).
  2. locales[primarySubtag]['pad.social.description'] (e.g. de-ATde).
  3. locales.en['pad.social.description'] (English fallback).
  4. Empty string (only if en.json is 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

  1. Settings — declare socialDescription: string on the Settings module with the default above; document it in both example settings files.
  2. 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 eejs wrapper does not support include() cleanly, fall back to a small JS helper (e.g. src/node/utils/socialMeta.ts) exported into the template via the existing eejs.require context, returning the rendered <meta> block as a string. Implementation step 1 of the plan must verify which mechanism eejs supports before committing to one.
  3. 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 with decodeURIComponent(req.params.pad) and HTML-escaped via the existing <%= %> mechanism (EJS escapes by default).
  4. Route handlersspecialpages.ts already passes req and settings to 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 an og:description matching the default.
  • GET /p/TestPad-7599 with settings.socialDescription overridden to "Custom desc" → that custom value appears in og:description.
  • GET /p/Has%20Spaceog:title contains Has Space (decoded) and is HTML-safe (no raw %).
  • GET /p/<script> (encoded) → og:title contains escaped &lt;script&gt;, not raw HTML.
  • GET /p/TestPad/timesliderog:title contains (history).
  • GET /og:title equals settings.title.
  • GET /p/TestPad with Accept-Language: de and socialDescription: {default: "X", de: "Y"}og:description is Y and og:locale is de_DE (or de).
  • Response includes og:image:alt and twitter: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:title does not expose anything new.
  • Caching. OG tags are read once per unfurl. Chat clients cache aggressively; changing socialDescription will 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 _socialMeta partial avoids three copies of the same block.

Out of scope (future work)

  • A padSocialMetadata hook 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:

  1. The default description is in English and there is no obvious place in settings.json to change it.
  2. 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 with Accept-Language negotiation, English fallback.
  • socialMeta.description: "<text>" → that string is used verbatim for og:description / twitter:description regardless 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:locale is unaffected; it continues to reflect the negotiated render language. Operators who want fully localised descriptions still use customLocaleStrings to override pad.social.description per-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.