') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ', 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + ', 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ', 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); })(); [2.x] feat(gambits): localization alias support — English keywords always work regardless of locale by imorland · Pull Request #4570 · flarum/framework · GitHub
Skip to content

[2.x] feat(gambits): localization alias support — English keywords always work regardless of locale - #4570

Open
imorland wants to merge 7 commits into
2.xfrom
im/gambit-localization-alias
Open

[2.x] feat(gambits): localization alias support — English keywords always work regardless of locale#4570
imorland wants to merge 7 commits into
2.xfrom
im/gambit-localization-alias

Conversation

@imorland

@imorlandimorland commented Apr 14, 2026

Copy link
Copy Markdown
Member

Summary

Resolves#4566.

  • Adds canonicalKey() and canonicalPattern() to BooleanGambit and KeyValueGambit. Both default to key(), so extensions that don't translate their keywords require no changes.
  • GambitManager.match() now tries the canonical English pattern as a second-pass alias when the translated pattern doesn't match — English keywords (is:hidden, author:behz, etc.) are always accepted regardless of the active locale.
  • Fixes SubscriptionFilter to accept only stable canonical values (follow/ignore) instead of raw locale strings leaking from the frontend. SubscriptionGambit.toFilter() now maps matched surface keywords to canonical values before sending them to the API.
  • Unrecognised subscription filter values now return an empty result set (WHERE 0 = 1) rather than crashing with a TypeError or returning all discussions.
  • Adds a Flarum\Subscriptions\Extend\Subscription extender so third-party extensions can register custom subscription types with their accepted filter aliases:
(newFlarum\Subscriptions\Extend\Subscription())
->addSubscriptionType('lurk', ['lurk', 'lurking', 'lurked'])

Extension authors

If your gambit's key() returns a translated string via app.translator.trans(), add a canonicalKey() override returning the hardcoded English keyword. That's the only change needed. See the companion docs PR flarum/docs#524 for full guidance.

Test plan

  • PHP: SubscriptionFilterTest — covers canonical values, surface aliases, negation, unrecognised values, and the Subscription extender (custom type registration + unregistered alias still returns empty)
  • JS: IGambit.test.ts — regression tests for all built-in gambit classes + TDD tests for canonicalKey() / canonicalPattern()
  • JS: GambitManager.test.ts — regression tests + localization alias describe block (translated vs canonical produce identical output, negation, unmatched keywords not consumed)

…ork regardless of locale
Adds `canonicalKey()` and `canonicalPattern()` to `BooleanGambit` and `KeyValueGambit`.
`GambitManager.match()` now tries the canonical English pattern as an alias when the
translated pattern doesn't match, so English keywords like `is:hidden` or `author:behz`
are always accepted even when the site locale has translated them.
Fixes `SubscriptionFilter` accepting raw locale strings from the frontend; it now
only accepts the stable canonical values `follow`/`ignore`. `SubscriptionGambit.toFilter()`
maps matched keywords to canonical values before sending them to the API.
Closes#4566
@imorland
imorland requested a review from a team as a code ownerApril 14, 2026 16:33
@imorlandimorland changed the title feat(gambits): localization alias support — English keywords always work regardless of locale[2.x] feat(gambits): localization alias support — English keywords always work regardless of localeApr 14, 2026
@imorlandimorland added this to the 2.0.0-rc.1 milestone Apr 14, 2026
imorlandand others added 4 commits April 14, 2026 17:40
…on types
Adds a Flarum\Subscriptions\Extend\Subscription extender so third-party
extensions can register custom subscription types (e.g. 'lurk') with their
accepted filter aliases:
(new Extend\Subscription())
->addSubscriptionType('lurk', ['lurk', 'lurking', 'lurked'])
SubscriptionFilter now resolves values through a container-bound registry
('flarum-subscriptions.subscription_types') rather than a hardcoded match
expression. The built-in 'follow' and 'ignore' types are seeded by the
subscriptions extension's own extend.php via the same extender.
…tures
- Add canonicalPattern() to the IGambit object literal in GambitsAutocomplete.tsx
so it satisfies the updated interface (TS2345)
- Add __esModule: true to the nanoid Jest mock so Jest's CJS/ESM interop resolves
the named export correctly
- Move lurk discussion/post/discussion_user fixtures into setUp() so the
subscription_extender_registers_custom_type test can find them
@imorlandimorland modified the milestones: 2.0.0-rc.1, 2.1Apr 14, 2026
Jest runs with --experimental-vm-modules which does real ESM linking, so CJS
mock files cannot satisfy named ESM imports. Replace emptyModule.js and
nanoid.js with proper .mjs equivalents using ESM export syntax.
Fix SubscriptionFilterTest: seed the 'lurk-user' fixture under user 3 (not
user 2) to avoid polluting the negation tests' result sets; switch negation
assertions from assertEqualsCanonicalizing to assertContains/assertNotContains
so they remain correct regardless of how many discussions exist.
Replace the lurk custom-type test (which required seeding an invalid enum value)
with a test that registers an additional alias for the existing 'follow' type,
which is a valid use of the extender and exercises the same code path.
@imorlandimorland added this to the 2.1 milestone Apr 17, 2026
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[2.x] Hardcoded gambit keywords prevent localization

2 participants

@imorland@StyleCIBot