Skip to content

Commit 90aafb1

Browse files
JohnMcLearclaude
andauthored
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>
1 parent ab0cff4 commit 90aafb1

7 files changed

Lines changed: 256 additions & 4 deletions

File tree

docs/superpowers/specs/2026-04-30-issue-7599-open-graph-metadata-design.md

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,3 +144,38 @@ The XSS escape test is the security-relevant one: pad IDs are user-controlled
144144
- A `padSocialMetadata` hook that lets plugins override the values.
145145
- Per-pad description (e.g. ep_pad_title integration).
146146
- Generated preview images (would require a rendering service).
147+
148+
## Follow-up (2026-05-07): operator description override
149+
150+
Issue #7599 follow-up comment from @stffen flagged two gaps in the shipped
151+
behaviour:
152+
153+
1. The default description is in English and there is no obvious place in
154+
`settings.json` to change it.
155+
2. The visitor's language is negotiated from `Accept-Language`, which most
156+
link-preview crawlers (WhatsApp, Signal, Slack, Telegram, Facebook) do not
157+
send — so non-English instances always serve the English fallback to
158+
crawlers regardless of which locale files exist.
159+
160+
Resolution: keep the i18n catalog as the default source (the original Qodo
161+
review still stands — translatable strings belong in locale files), but add
162+
an explicit `settings.socialMeta.description` override that wins when set:
163+
164+
- `socialMeta.description: null` (default) → existing behaviour: i18n
165+
catalog with `Accept-Language` negotiation, English fallback.
166+
- `socialMeta.description: "<text>"` → that string is used verbatim for
167+
`og:description` / `twitter:description` regardless of the negotiated
168+
language. This is the lever that fixes the crawler-no-Accept-Language
169+
case.
170+
- Empty / whitespace-only override is treated as unset (would otherwise
171+
blank out previews silently — a footgun).
172+
- The override is HTML-escaped via the same path as every other
173+
interpolated value.
174+
- `og:locale` is unaffected; it continues to reflect the negotiated render
175+
language. Operators who want fully localised descriptions still use
176+
`customLocaleStrings` to override `pad.social.description` per-language.
177+
178+
Documentation lives next to `publicURL` in both `settings.json.template`
179+
and `settings.json.docker` (mirrors how the original feature is
180+
configured), and the `customLocaleStrings` example now shows the
181+
`pad.social.description` key explicitly so operators can find both routes.

settings.json.docker

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,19 @@
125125
*/
126126
"publicURL": "${PUBLIC_URL:null}",
127127

128+
/*
129+
* Open Graph / Twitter Card metadata for link previews.
130+
*
131+
* SOCIAL_META_DESCRIPTION: when set, this exact text is used as
132+
* og:description regardless of negotiated language. Most preview crawlers
133+
* (WhatsApp, Signal, Slack, ...) don't send Accept-Language, so without an
134+
* override they always hit the English fallback in the i18n catalog.
135+
* Leave unset (null) to use the catalog (key `pad.social.description`).
136+
*/
137+
"socialMeta": {
138+
"description": "${SOCIAL_META_DESCRIPTION:null}"
139+
},
140+
128141
/*
129142
* Skin name.
130143
*

settings.json.template

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,26 @@
123123
*/
124124
"publicURL": null,
125125

126+
/*
127+
* Open Graph / Twitter Card metadata, served on the homepage, pad pages and
128+
* timeslider for nicer previews when a pad URL is shared in chat apps
129+
* (WhatsApp, Signal, Slack, ...).
130+
*
131+
* - description: when set to a non-empty string, this exact text is used as
132+
* og:description / twitter:description regardless of the visitor's
133+
* negotiated language. Most link-preview crawlers don't send an
134+
* Accept-Language header, so without an override they always see the
135+
* English fallback. Set this if your instance serves a non-English
136+
* audience and you want a fixed blurb in shared previews.
137+
*
138+
* Leave description as null to use Etherpad's i18n catalog (key
139+
* `pad.social.description`), which honours Accept-Language and can be
140+
* overridden per-language via `customLocaleStrings` further down.
141+
*/
142+
"socialMeta": {
143+
"description": null
144+
},
145+
126146
/*
127147
* Skin name.
128148
*
@@ -820,7 +840,19 @@
820840
*/
821841
"logLayoutType": "colored",
822842

823-
/* Override any strings found in locale directories */
843+
/*
844+
* Override any strings found in locale directories.
845+
*
846+
* Format: { "<lang>": { "<key>": "<text>", ... }, ... }
847+
* Example, per-language Open Graph description for link previews:
848+
* "customLocaleStrings": {
849+
* "en": { "pad.social.description": "Our team's collaborative pads." },
850+
* "de": { "pad.social.description": "Kollaborative Notizblöcke." }
851+
* }
852+
* For a single description regardless of language, prefer
853+
* `socialMeta.description` above — link-preview crawlers usually don't
854+
* send Accept-Language and otherwise hit the English fallback.
855+
*/
824856
"customLocaleStrings": {},
825857

826858
/* Disable Admin UI tests */

src/node/utils/Settings.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,9 @@ export type SettingsType = {
165165
showRecentPads: boolean,
166166
favicon: string | null,
167167
publicURL: string | null,
168+
socialMeta: {
169+
description: string | null,
170+
},
168171
ttl: {
169172
AccessToken: number,
170173
AuthorizationCode: number,
@@ -360,6 +363,24 @@ const settings: SettingsType = {
360363
* No trailing slash. Must include scheme.
361364
*/
362365
publicURL: null,
366+
367+
/**
368+
* Open Graph / Twitter Card metadata, served on the homepage, pad pages and
369+
* timeslider for nicer previews when a pad URL is shared in chat apps.
370+
*
371+
* description: when non-null, this exact string is used as og:description /
372+
* twitter:description regardless of the visitor's negotiated language. Most
373+
* crawlers (WhatsApp, Signal, Telegram, Slack, Facebook) don't send an
374+
* Accept-Language header, so without an override they always see the
375+
* English fallback — set this if your instance serves a non-English
376+
* audience and you want a fixed blurb. Leave null to use Etherpad's
377+
* built-in i18n catalog (key `pad.social.description`), which honours the
378+
* visitor's Accept-Language and can be overridden per-language via the
379+
* standard `customLocaleStrings` mechanism below.
380+
*/
381+
socialMeta: {
382+
description: null,
383+
},
363384
ttl: {
364385
AccessToken: 1 * 60 * 60, // 1 hour in seconds
365386
AuthorizationCode: 10 * 60, // 10 minutes in seconds

src/node/utils/socialMeta.ts

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,13 @@ import type {Request} from 'express';
99
* XSS via crafted pad IDs.
1010
*
1111
* The description text is sourced from Etherpad's i18n catalog under the key
12-
* `pad.social.description`. Operators can override it per-language via the
13-
* standard `customLocaleStrings` mechanism in settings.json.
12+
* `pad.social.description`. Operators have two ways to override it:
13+
* - `settings.socialMeta.description` — flat string used regardless of
14+
* negotiated language. Useful because most link-preview crawlers don't
15+
* send Accept-Language and would otherwise always hit the English fallback.
16+
* - `customLocaleStrings` — per-language override that participates in
17+
* normal Accept-Language negotiation.
18+
* The flat setting wins over the i18n catalog when set.
1419
*/
1520

1621
const SOCIAL_DESCRIPTION_KEY = 'pad.social.description';
@@ -96,6 +101,9 @@ type SocialMetaSettings = {
96101
title?: string,
97102
favicon?: string | null,
98103
publicURL?: string | null,
104+
socialMeta?: {
105+
description?: string | null,
106+
},
99107
};
100108

101109
const negotiateRenderLang = (req: Request, availableLangs: AvailableLangs): string => {
@@ -153,10 +161,25 @@ export type RenderOpts = {
153161
padName?: string,
154162
};
155163

164+
// Operator override wins, but only when it's a non-empty string. An empty
165+
// string from settings would silently blank out og:description / twitter:
166+
// description and break previews, so we treat empty/whitespace-only as unset
167+
// and fall back to the i18n catalog.
168+
const resolveDescriptionWithOverride = (
169+
override: string | null | undefined,
170+
locales: {[lang: string]: {[key: string]: string}} | undefined,
171+
renderLang: string,
172+
): string => {
173+
if (typeof override === 'string' && override.trim() !== '') return override;
174+
return resolveDescription(locales, renderLang);
175+
};
176+
156177
export const renderSocialMeta = (o: RenderOpts): string => {
157178
const renderLang = negotiateRenderLang(o.req, o.availableLangs);
158179
const siteName = o.settings.title || 'Etherpad';
159-
const description = resolveDescription(o.locales, renderLang);
180+
const description = resolveDescriptionWithOverride(
181+
o.settings.socialMeta && o.settings.socialMeta.description,
182+
o.locales, renderLang);
160183
const imageUrl = resolveImageUrl(o.req, o.settings.favicon, o.settings.publicURL);
161184
const imageAlt = `${siteName} logo`;
162185

src/tests/backend/specs/socialMeta-unit.ts

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,89 @@ describe(__filename, function () {
168168
});
169169
});
170170

171+
describe('renderSocialMeta — settings.socialMeta.description override', function () {
172+
it('overrides i18n catalog regardless of negotiated language', function () {
173+
// Crawler sends de, catalog has both en and de entries — operator
174+
// override wins anyway. This is the crawler-no-Accept-Language case.
175+
const html = renderSocialMeta({
176+
req: fakeReq({acceptsLanguages: () => 'de'}),
177+
settings: {
178+
title: 'Etherpad', favicon: null,
179+
socialMeta: {description: 'Operator-set blurb'},
180+
},
181+
availableLangs: {en: {}, de: {}},
182+
locales: {
183+
en: {'pad.social.description': 'En catalog'},
184+
de: {'pad.social.description': 'De catalog'},
185+
},
186+
kind: 'pad', padName: 'P',
187+
});
188+
assert.equal(ogTag(html, 'og:description'), 'Operator-set blurb');
189+
assert.equal(ogTag(html, 'twitter:description'), 'Operator-set blurb');
190+
});
191+
192+
it('null override falls back to i18n catalog', function () {
193+
const html = renderSocialMeta({
194+
req: fakeReq({acceptsLanguages: () => 'de'}),
195+
settings: {
196+
title: 'Etherpad', favicon: null,
197+
socialMeta: {description: null},
198+
},
199+
availableLangs: {en: {}, de: {}},
200+
locales: {
201+
en: {'pad.social.description': 'En'},
202+
de: {'pad.social.description': 'De'},
203+
},
204+
kind: 'pad', padName: 'P',
205+
});
206+
assert.equal(ogTag(html, 'og:description'), 'De');
207+
});
208+
209+
it('empty / whitespace override does NOT silence the description', function () {
210+
// An accidental empty string in settings.json must not blank out the tag —
211+
// we'd lose previews entirely. Treat it as unset.
212+
for (const blank of ['', ' ', '\t\n']) {
213+
const html = renderSocialMeta({
214+
req: fakeReq({acceptsLanguages: () => 'en'}),
215+
settings: {
216+
title: 'Etherpad', favicon: null,
217+
socialMeta: {description: blank},
218+
},
219+
availableLangs: {en: {}},
220+
locales: {en: {'pad.social.description': 'Catalog wins'}},
221+
kind: 'pad', padName: 'P',
222+
});
223+
assert.equal(ogTag(html, 'og:description'), 'Catalog wins',
224+
`blank override (${JSON.stringify(blank)}) should fall back`);
225+
}
226+
});
227+
228+
it('HTML-escapes the override (it is operator-controlled but renders into HTML)', function () {
229+
const html = renderSocialMeta({
230+
req: fakeReq(),
231+
settings: {
232+
title: 'Etherpad', favicon: null,
233+
socialMeta: {description: 'A & B "<C>"'},
234+
},
235+
availableLangs: {en: {}}, locales: enLocales,
236+
kind: 'pad', padName: 'P',
237+
});
238+
assert.equal(ogTag(html, 'og:description'), 'A &amp; B &quot;&lt;C&gt;&quot;');
239+
});
240+
241+
it('missing socialMeta block is treated as unset', function () {
242+
// Older settings.json files won't have the socialMeta block at all.
243+
const html = renderSocialMeta({
244+
req: fakeReq({acceptsLanguages: () => 'en'}),
245+
settings: {title: 'Etherpad', favicon: null},
246+
availableLangs: {en: {}},
247+
locales: {en: {'pad.social.description': 'Catalog'}},
248+
kind: 'pad', padName: 'P',
249+
});
250+
assert.equal(ogTag(html, 'og:description'), 'Catalog');
251+
});
252+
});
253+
171254
describe('renderSocialMeta — image URL', function () {
172255
it('builds absolute URL to /favicon.ico when settings.favicon is null', function () {
173256
const html = renderSocialMeta({

src/tests/backend/specs/socialMeta.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,11 +24,15 @@ describe(__filename, function () {
2424
beforeEach(async function () {
2525
backup.title = settings.title;
2626
backup.favicon = settings.favicon;
27+
backup.socialMeta = settings.socialMeta;
28+
// Default shape — every test starts with no override.
29+
settings.socialMeta = {description: null};
2730
});
2831

2932
afterEach(async function () {
3033
settings.title = backup.title;
3134
settings.favicon = backup.favicon;
35+
settings.socialMeta = backup.socialMeta;
3236
});
3337

3438
describe('pad page', function () {
@@ -121,4 +125,45 @@ describe(__filename, function () {
121125
assert.equal(ogTag(res.text, 'og:title'), settings.title);
122126
});
123127
});
128+
129+
describe('settings.socialMeta.description override', function () {
130+
it('overrides og:description and twitter:description', async function () {
131+
settings.socialMeta = {description: 'Custom blurb for issue 7599'};
132+
const res = await agent.get('/p/TestPad7599').expect(200);
133+
assert.equal(ogTag(res.text, 'og:description'), 'Custom blurb for issue 7599');
134+
assert.equal(ogTag(res.text, 'twitter:description'), 'Custom blurb for issue 7599');
135+
});
136+
137+
it('override beats Accept-Language negotiation', async function () {
138+
// Crawlers (WhatsApp/Signal/etc.) typically send no Accept-Language and
139+
// would otherwise always hit the English fallback. Operator override
140+
// wins regardless of the negotiated locale.
141+
settings.socialMeta = {description: 'Operator wins'};
142+
const res = await agent.get('/p/TestPad7599')
143+
.set('Accept-Language', 'de').expect(200);
144+
assert.equal(ogTag(res.text, 'og:description'), 'Operator wins');
145+
});
146+
147+
it('blank override falls back to i18n catalog (does not silence preview)', async function () {
148+
settings.socialMeta = {description: ' '};
149+
const res = await agent.get('/p/TestPad7599')
150+
.set('Accept-Language', 'en').expect(200);
151+
const desc = ogTag(res.text, 'og:description');
152+
assert.ok(desc && desc.length > 0,
153+
'blank override should not blank out og:description');
154+
assert.match(desc!, /collaborative/i);
155+
});
156+
157+
it('HTML-escapes the override', async function () {
158+
settings.socialMeta = {description: 'A & B <c> "d"'};
159+
const res = await agent.get('/p/TestPad7599').expect(200);
160+
// The HTML body contains the *escaped* form; the parsed attribute value
161+
// (what ogTag returns) is the unescaped logical string the meta tag
162+
// exposes — assert both: no raw <c> in the served HTML, and the logical
163+
// value round-trips correctly.
164+
assert.ok(!/content="[^"]*<c>/.test(res.text),
165+
'raw "<c>" must not appear inside content="..."');
166+
assert.equal(ogTag(res.text, 'og:description'), 'A &amp; B &lt;c&gt; &quot;d&quot;');
167+
});
168+
});
124169
});

0 commit comments

Comments
 (0)