Skip to content

Commit 0ea52bc

Browse files
JohnMcLearclaude
andcommitted
fix(padToggle): require defaultLabel for screen-reader fallback
The empty <label data-l10n-id="..."></label> meant screen readers had nothing to announce while html10n was still loading or if a translation failed to fetch. Native Etherpad's templates always render English text inside the label tag for exactly this reason; the helper was missing that safety net. Make defaultLabel a required config field. Helper now renders <label data-l10n-id="..." for="...">Default text</label> with both the defaultLabel and l10nId HTML-escaped to prevent injection. html10n still overwrites the text the moment it loads in the user's locale. Validate on both server and client factories so a misconfigured plugin fails loudly on import rather than silently shipping a nameless checkbox. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent c409de5 commit 0ea52bc

4 files changed

Lines changed: 55 additions & 14 deletions

File tree

README.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -192,7 +192,9 @@ const {padToggle} = require('ep_plugin_helpers');
192192
const t = padToggle({
193193
pluginName: 'ep_myplugin', // must match /^ep_[a-z0-9_]+$/
194194
settingId: 'my-feature', // → ids: options-my-feature, padsettings-options-my-feature
195-
l10nId: 'ep_myplugin.myFeature', // i18n key (no hardcoded English label)
195+
l10nId: 'ep_myplugin.myFeature', // i18n key, html10n overwrites the fallback
196+
defaultLabel: 'My feature', // a11y fallback — rendered inside <label> so screen readers
197+
// announce something before html10n loads
196198
defaultEnabled: false, // overridable via settings.json[pluginName].defaultEnabled
197199
});
198200

pad-toggle-server.js

Lines changed: 19 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -26,11 +26,15 @@ try {
2626
padOptionsPluginPassthrough = caps && caps.padOptionsPluginPassthrough === true;
2727
} catch (_e) { /* older core — leave as false */ }
2828

29+
const HTML_ESCAPE_RE = /[&<>"']/g;
30+
const HTML_ESCAPES = {'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;'};
31+
const escapeHtml = (s) => String(s).replace(HTML_ESCAPE_RE, (c) => HTML_ESCAPES[c]);
32+
2933
const validateConfig = (config) => {
3034
if (!config || typeof config !== 'object') {
3135
throw new Error('padToggle requires a config object');
3236
}
33-
const {pluginName, settingId, l10nId, defaultEnabled = true} = config;
37+
const {pluginName, settingId, l10nId, defaultLabel, defaultEnabled = true} = config;
3438
if (!PLUGIN_NAME_RE.test(pluginName || '')) {
3539
throw new Error(
3640
`padToggle pluginName must match /^ep_[a-z0-9_]+$/, got: ${pluginName}`);
@@ -39,19 +43,26 @@ const validateConfig = (config) => {
3943
throw new Error('padToggle requires settingId (string)');
4044
}
4145
if (!l10nId || typeof l10nId !== 'string') {
42-
throw new Error('padToggle requires l10nId (string) — never hardcode a label');
46+
throw new Error('padToggle requires l10nId (string) — i18n is mandatory');
47+
}
48+
if (!defaultLabel || typeof defaultLabel !== 'string') {
49+
throw new Error(
50+
'padToggle requires defaultLabel (string) — accessibility fallback ' +
51+
'rendered inside <label> so screen readers announce something before ' +
52+
'html10n loads. html10n overwrites it at runtime via data-l10n-id.');
4353
}
44-
return {pluginName, settingId, l10nId, defaultEnabled: !!defaultEnabled};
54+
return {pluginName, settingId, l10nId, defaultLabel, defaultEnabled: !!defaultEnabled};
4555
};
4656

47-
const renderCheckbox = (settingId, l10nId, idPrefix) =>
57+
const renderCheckbox = (settingId, l10nId, defaultLabel, idPrefix) =>
4858
`<p>` +
4959
`<input type="checkbox" id="${idPrefix}options-${settingId}">` +
50-
`<label for="${idPrefix}options-${settingId}" data-l10n-id="${l10nId}"></label>` +
60+
`<label for="${idPrefix}options-${settingId}" ` +
61+
`data-l10n-id="${escapeHtml(l10nId)}">${escapeHtml(defaultLabel)}</label>` +
5162
`</p>`;
5263

5364
const padToggleServer = (rawConfig) => {
54-
const {pluginName, settingId, l10nId, defaultEnabled} = validateConfig(rawConfig);
65+
const {pluginName, settingId, l10nId, defaultLabel, defaultEnabled} = validateConfig(rawConfig);
5566
let cachedDefaultEnabled = defaultEnabled;
5667

5768
const loadSettings = async (hookName, args) => {
@@ -84,13 +95,13 @@ const padToggleServer = (rawConfig) => {
8495
};
8596

8697
const eejsBlock_mySettings = (hookName, args, cb) => {
87-
args.content += renderCheckbox(settingId, l10nId, '');
98+
args.content += renderCheckbox(settingId, l10nId, defaultLabel, '');
8899
return cb();
89100
};
90101

91102
const eejsBlock_padSettings = (hookName, args, cb) => {
92103
if (!padOptionsPluginPassthrough) return cb();
93-
args.content += renderCheckbox(settingId, l10nId, 'padsettings-');
104+
args.content += renderCheckbox(settingId, l10nId, defaultLabel, 'padsettings-');
94105
return cb();
95106
};
96107

pad-toggle.js

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,13 @@ const validateConfig = (config) => {
2626
throw new Error('padToggle requires settingId (string)');
2727
}
2828
if (!l10nId || typeof l10nId !== 'string') {
29-
throw new Error('padToggle requires l10nId (string) — never hardcode a label');
29+
throw new Error('padToggle requires l10nId (string) — i18n is mandatory');
30+
}
31+
// Client side does no rendering, but the same validation runs here so an
32+
// author who forgets defaultLabel on the client gets the same loud error
33+
// as on the server.
34+
if (!config.defaultLabel || typeof config.defaultLabel !== 'string') {
35+
throw new Error('padToggle requires defaultLabel (string) — a11y fallback for screen readers');
3036
}
3137
return {pluginName, settingId, l10nId, defaultEnabled: !!defaultEnabled};
3238
};

test/pad-toggle.js

Lines changed: 26 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ const baseConfig = () => ({
77
pluginName: 'ep_test',
88
settingId: 'thing',
99
l10nId: 'ep_test.thing',
10+
defaultLabel: 'Show Thing',
1011
});
1112

1213
describe('padToggle', () => {
@@ -20,11 +21,15 @@ describe('padToggle', () => {
2021
/pluginName must match/);
2122
});
2223

23-
it('throws when settingId or l10nId is missing', () => {
24+
it('throws when settingId, l10nId, or defaultLabel is missing', () => {
2425
assert.throws(() => padToggle({...baseConfig(), settingId: ''}),
2526
/settingId/);
2627
assert.throws(() => padToggle({...baseConfig(), l10nId: ''}),
2728
/l10nId/);
29+
assert.throws(() => padToggle({...baseConfig(), defaultLabel: ''}),
30+
/defaultLabel/);
31+
assert.throws(() => padToggle({...baseConfig(), defaultLabel: undefined}),
32+
/defaultLabel/);
2833
});
2934

3035
it('returns the full server hook surface for valid config', () => {
@@ -46,14 +51,31 @@ describe('padToggle', () => {
4651
});
4752

4853
describe('eejsBlock_mySettings', () => {
49-
it('emits a checkbox with namespaced id and data-l10n-id label', (done) => {
54+
it('emits a checkbox with namespaced id, data-l10n-id, and label fallback', (done) => {
5055
const t = padToggle(baseConfig());
5156
const args = {content: ''};
5257
t.eejsBlock_mySettings('hook', args, () => {
5358
assert.match(args.content, /id="options-thing"/);
5459
assert.match(args.content, /data-l10n-id="ep_test\.thing"/);
55-
// No hardcoded English fallback inside the label tag.
56-
assert.match(args.content, /data-l10n-id="ep_test\.thing"><\/label>/);
60+
// a11y fallback: <label> must contain the default text so screen
61+
// readers announce something even if html10n hasn't loaded yet.
62+
assert.match(args.content, /data-l10n-id="ep_test\.thing">Show Thing<\/label>/);
63+
done();
64+
});
65+
});
66+
67+
it('HTML-escapes the defaultLabel and l10nId to prevent injection', (done) => {
68+
const t = padToggle({
69+
...baseConfig(),
70+
defaultLabel: 'A & B <script>',
71+
l10nId: 'ep_test.thing"onerror',
72+
});
73+
const args = {content: ''};
74+
t.eejsBlock_mySettings('hook', args, () => {
75+
assert.ok(!args.content.includes('<script>'),
76+
'raw <script> must not survive into rendered HTML');
77+
assert.match(args.content, /A &amp; B &lt;script&gt;/);
78+
assert.match(args.content, /data-l10n-id="ep_test\.thing&quot;onerror"/);
5779
done();
5880
});
5981
});

0 commit comments

Comments
 (0)