-
-
Notifications
You must be signed in to change notification settings - Fork 3k
feat: Open Graph & Twitter Card metadata for pad/timeslider/home (closes #7599) #7635
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
JohnMcLear
wants to merge
8
commits into
ether:develop
Choose a base branch
from
JohnMcLear:feat/og-metadata-7599
base: develop
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
8 commits
Select commit
Hold shift + click to select a range
2992345
docs(spec): Open Graph metadata for pad pages (issue #7599)
JohnMcLear ab326f0
docs(spec): expand OG spec — i18n (locale map + og:locale) and a11y (…
JohnMcLear 0f67d38
feat: emit Open Graph & Twitter Card metadata for pad/timeslider/home
JohnMcLear cf5e926
fix(test): use valid pad-name char in URL-decode test
JohnMcLear 3f2e2ce
fix(socialMeta): don't double-decode pad name from req.params.pad
JohnMcLear 0ae11c5
refactor(socialMeta): source description from i18n catalog, drop sett…
JohnMcLear 0f5295a
test(socialMeta): add unit tests for pure helpers
JohnMcLear 62b86c7
fix(socialMeta): defend og:url/og:image against host-header poisoning
JohnMcLear File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
146 changes: 146 additions & 0 deletions
146
docs/superpowers/specs/2026-04-30-issue-7599-open-graph-metadata-design.md
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
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,146 @@ | ||
| # 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} | {settings.title}` | | ||
| | `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-AT` → `de`). | ||
| 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 handlers** — `specialpages.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%20Space` → `og:title` contains `Has Space` (decoded) and is | ||
| HTML-safe (no raw `%`). | ||
| - GET `/p/<script>` (encoded) → `og:title` contains escaped `<script>`, | ||
| not raw HTML. | ||
| - GET `/p/TestPad/timeslider` → `og: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). |
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
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
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
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
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
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
Oops, something went wrong.
Oops, something went wrong.
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.
Uh oh!
There was an error while loading. Please reload this page.