Skip to content

Defuse links in X posts to avoid the link-post fee - #308

Merged
paulocastellano merged 1 commit into
mainfrom
feat/defuse-x-links
Aug 29, 2026
Merged

Defuse links in X posts to avoid the link-post fee#308
paulocastellano merged 1 commit into
mainfrom
feat/defuse-x-links

Conversation

@paulocastellano

@paulocastellano paulocastellano commented Aug 29, 2026

Copy link
Copy Markdown
Contributor

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:

https://example.com/post   ->  example(.)com/post
http://www.example.com     ->  example(.)com
example.com                ->  example(.)com
blog.example.com.br/x      ->  blog(.)example(.)com(.)br/x
example.com/file.pdf       ->  example(.)com/file.pdf

Scheme and www. are dropped and every dot of the host is broken. Defusing only the dot before the TLD would leave blog.example.com intact inside blog.example.com(.)br, which X still detects.

It lives in the Platform::X arm of ContentSanitizer::sanitize(), which already dispatches per platform: one line in the match plus a private method. No new class, and no $defuseLinks parameter for call sites to forget. Because PostPreviewer goes through the same sanitizer, the app, REST and MCP previews show the real text for free.

Env Config Default Effect
X_DEFUSE_LINKS trypost.platforms.x.defuse_links false when SELF_HOSTED, true otherwise Rewrites links in the X version of a post

Self-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.example and compose.prod.yaml.

Character limits now measure what is actually sent

validateContentLength (publish) and ContentFitsPlatformLimits (save/schedule) both measured the raw draft. With defusing that breaks in both directions:

Case Raw (validated) Sent to X
271 chars + acme.com 280, accepted 282, X rejects
263 chars + https://acme.com/x 282, rejected 276, would have fit

Both 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 and www., plus path, query string, fragment, path dots, uppercase, hyphenated labels and surrounding punctuation.

Negative cases assert Node.js, e.g., 3.5, file.pdf and 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:// or www. is a URL by construction, so it is defused on sight with no list involved.

A bare host is the only ambiguous case: acme.com and Node.js are structurally identical, and the single thing separating them is whether the last label is a delegated TLD. That check goes through App\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.md and backup.zip are defused, because .md and .zip are 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@edit sends App\Support\LinkTlds::all() as the xLinkTlds page 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 from Node.js, so an empty set means every platform gets its text back untouched.

Two tests keep the mirror honest. XLinkDefusingParityTest runs 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.php drives 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 SyntaxError there 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

  • After deploy, publish one real link post and confirm on the X invoice that it billed at the plain-post rate. That premise cannot be verified any other way.
  • Internationalised TLDs are matched, but a Unicode host is only recognised where the script's characters are letters, digits or combining marks. Anything outside that falls through and is billed rather than mangled.

@paulocastellano paulocastellano changed the title Defuse links in X posts to avoid the $0.20 link-post fee Defuse links in X posts to avoid the link-post fee Aug 29, 2026
@paulocastellano
paulocastellano force-pushed the feat/defuse-x-links branch 12 times, most recently from 11b617d to 173f600 Compare August 29, 2026 18:13
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
paulocastellano merged commit 6496588 into main Aug 29, 2026
5 checks passed
@paulocastellano
paulocastellano deleted the feat/defuse-x-links branch August 29, 2026 18:31
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant