Defuse links in X posts to avoid the link-post fee - #308
Merged
Conversation
paulocastellano
force-pushed
the
feat/defuse-x-links
branch
from
August 29, 2026 16:13
c71a450 to
9c829ed
Compare
paulocastellano
force-pushed
the
feat/defuse-x-links
branch
12 times, most recently
from
August 29, 2026 18:13
11b617d to
173f600
Compare
X bills a post containing a URL at a much higher rate than a plain post, and its algorithm demotes link posts. The X version of a post now rewrites every URL non-clickable (https://example.com/post becomes example(.)com/post): scheme and www. dropped, every dot of the host replaced with (.). Leaving a single dot intact would still leave a resolvable domain for X to detect, so all of them are broken. A scheme or www. proves a token is a URL on its own; a bare host only counts when its last label is a delegated TLD, which is the one thing telling acme.com apart from Node.js. That check runs against App\Support\LinkTlds, generated from the whole IANA root zone in every form a TLD can appear in a post -- ASCII, punycode and the Unicode it decodes to -- because whatever X links is what X bills, so a hand-picked subset would leave us paying for its gaps. If the regex engine bails out on pathological input the original content is returned instead of crashing the publisher. The transform lives in the Platform::X arm of ContentSanitizer, so it reaches publishing and the app/API/MCP previews from one place and cannot touch any other network. Off by default; opt in with X_DEFUSE_LINKS. The editor counts characters and renders its preview client-side and cannot ask the server on every keystroke, so the rewrite is mirrored in TypeScript. PHP stays the source of truth: a parity test fails if the two TLD sets drift, and a browser test drives the real editor so the mirror is covered rather than assumed. Without it the composer promised text the network never receives. Character limits now measure the text a reader will see: sanitized, then with markup resolved away. Measuring the raw draft blocked saving posts that publish fine and let through posts the network rejects, and counted the editor's HTML toward the limit. Measuring the sanitized form alone would have counted Telegram's escaped entities, rejecting messages Telegram accepts. Empty content is handled once inside the sanitizer instead of by a guard repeated at every call site.
paulocastellano
force-pushed
the
feat/defuse-x-links
branch
from
August 29, 2026 18:21
173f600 to
83ed7d6
Compare
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
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
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.
Why
X charges a significantly higher per-post rate through its API when the post contains a URL, and its algorithm demotes link posts. Rewriting the link so it is no longer detected as one avoids the higher rate and the reach penalty, without blocking anyone from posting what they want.
What changes
The X version of a post now has every URL rewritten non-clickable:
Scheme and
www.are dropped and every dot of the host is broken. Defusing only the dot before the TLD would leaveblog.example.comintact insideblog.example.com(.)br, which X still detects.It lives in the
Platform::Xarm ofContentSanitizer::sanitize(), which already dispatches per platform: one line in thematchplus a private method. No new class, and no$defuseLinksparameter for call sites to forget. BecausePostPreviewergoes through the same sanitizer, the app, REST and MCP previews show the real text for free.X_DEFUSE_LINKStrypost.platforms.x.defuse_linksfalsewhenSELF_HOSTED,trueotherwiseSelf-hosted installs publish through their own X app and pay their own API bill, so it is off there by default and documented in
.env.exampleandcompose.prod.yaml.Character limits now measure what is actually sent
validateContentLength(publish) andContentFitsPlatformLimits(save/schedule) both measured the raw draft. With defusing that breaks in both directions:acme.comhttps://acme.com/xBoth now measure the sanitized content. This also fixes a pre-existing bug neither had covered: the editor stores HTML, so
<p>and<strong>were counted toward the network's character limit.Testing
65 new tests, all TDD'd. The host-shape matrix is a Pest dataset covering bare domains, two-level TLDs (
.com.br,.co.uk,.com.au), one/two/three levels of subdomain, each with and without scheme andwww., plus path, query string, fragment, path dots, uppercase, hyphenated labels and surrounding punctuation.Negative cases assert
Node.js,e.g.,3.5,file.pdfand email addresses are untouched, and the same host shapes are asserted intact on LinkedIn, Instagram and Bluesky.Full suite: 4011 passed, 1 skipped. Pint clean.
How a link is recognised
A token carrying
https://,http://orwww.is a URL by construction, so it is defused on sight with no list involved.A bare host is the only ambiguous case:
acme.comandNode.jsare structurally identical, and the single thing separating them is whether the last label is a delegated TLD. That check goes throughApp\Support\LinkTlds, generated from the full IANA root zone (1287 ASCII TLDs, snapshot pinned in the class docblock with the command to regenerate it). Mirroring X's own list rather than a hand-picked subset is deliberate: whatever X turns into a link is exactly what it bills as one, so a curated list would leave us paying for its gaps.This means
README.mdandbackup.zipare defused, because.mdand.zipare real TLDs and X links them too. That reads oddly but is correct: those already become billable links on X today.Internationalised (
xn--) TLDs are excluded — post content carries them in Unicode form, never as punycode, so listing them would never match.The lookup is a flipped array built once per process, so the pattern stays a small generic regex instead of a 1287-branch alternation. Defusing 2000 posts takes ~27 ms.
The editor no longer disagrees with the server
The composer counts characters and renders its preview client-side, and it cannot ask the server on every keystroke, so the rewrite is mirrored in
resources/js/lib/defuseXLinks.ts. Before this, the X preview showed a link the network would never receive, and the counter was wrong in both directions.The TLD list is not duplicated there.
PostController@editsendsApp\Support\LinkTlds::all()as thexLinkTldspage prop — not as a shared prop, since only the editor needs it — and only while defusing is on. The presence of the list is therefore the flag itself: without it a bare host cannot be told fromNode.js, so an empty set means every platform gets its text back untouched.Two tests keep the mirror honest.
XLinkDefusingParityTestruns a shared corpus of 57 entries through PCRE and through the JavaScript engine over the same list, and diffs the output — the harness receives the TLDs from PHP exactly as the editor does.tests/Browser/XLinkDefusingTest.phpdrives the real editor: preview on, preview off, and the counter.Neither expression may use lookbehind. Safari only understands it from 16.4, esbuild cannot transpile it, and a
SyntaxErrorthere takes down the whole chunk rather than degrading — the character before a candidate URL is consumed and put back instead.The overflow highlight in the textarea now measures its allowance in draft characters, shifted by whatever the rewrite adds or removes, so it lands on the text that actually spills.
Follow-ups