For about a month, every social preview card on a site I build was silently cut off mid-word. Not truncated with an ellipsis — cut inside a word. PDF & image tools, con. The con is converters, sliced in half.
Nobody reported it. Preview cards are the one thing you never see on your own site — they only show up when someone else shares your link on Slack, Discord, or X. So it just sat there, on every page, in every language.
Here's the line that did it:
// OG card subtitle
<text ...>${esc(String(sub).slice(0, 60))}</text>
slice(0, 60). Take the first 60 characters. Simple, obvious, and wrong — because "60 characters" has no idea where words end. If character 60 lands in the middle of converters, you ship conv.
Why it survived so long
Two reasons, and both are worth internalizing:
The output is invisible to the author. OG cards render off-site. My build succeeded, the page looked fine, the card image generated without error. Everything green. The bug only exists in the one context I never look at.
It was in a shared template. One
ogSvg()function renders the card for every tool page. So the bug wasn't on one page — it was on 164 tools × the locales each ships. 836 cards, all the same slice, all cut mid-word.
That second point is the real lesson. A bug in a leaf component hurts one page. A bug in the thing that generates every page hurts all of them at once, and it does it quietly.
The naive fix, and why it isn't enough
The obvious fix is "cut at the last space instead of at character 60":
const cut = s.slice(0, n);
return cut.slice(0, cut.lastIndexOf(" ")) + "…";
This works great for English. It falls apart the moment you have more than one language.
Japanese, Chinese, and Korean don't put spaces between words. 無料オンラインツール is one run of characters with no space to find. lastIndexOf(" ") returns -1, slice(0, -1) chops off the last character, and now your CJK subtitle is missing a character and has a floating ellipsis. You traded a visible English bug for a subtle CJK one.
What actually shipped
function clip(s, n) {
s = String(s == null ? "" : s).trim();
if (s.length <= n) return s;
const cut = s.slice(0, n);
const sp = cut.lastIndexOf(" ");
// Latin scripts break on the space. CJK has no spaces, so if there's no
// space — or it's too early to be a real word boundary — just hard-cut.
return (sp > n * 0.5 ? cut.slice(0, sp) : cut).replace(/[,·、,\s]+$/, "") + "…";
}
The sp > n * 0.5 guard is the whole trick. If the last space sits in a reasonable spot, break there (Latin). If there's no space, or it's suspiciously early — meaning it's probably not a real word boundary — hard-cut the character run (CJK). The trailing replace strips a dangling comma or CJK punctuation so you never get image tools,….
It's not linguistically perfect. It doesn't need to be. It needs to never cut converters into conv and never eat a Japanese character. Those two failures are what people actually see.
The takeaway
If you generate anything from a shared template — OG cards, emails, PDFs, filenames — the failure mode isn't "one broken thing." It's "one broken thing, replicated everywhere, in the one place you never look." Two habits catch it:
- Look at the artifact, not the build log. The build passing tells you nothing about whether the card reads correctly.
-
slice()is not a text-truncation function. It's a character-count function. The moment human-readable text meets a non-space-delimited language, they stop being the same thing.
I hit this building Toolio, a set of small browser tools that ship in 16 languages — which is exactly why the CJK edge case wasn't optional.
Top comments (2)
The part that stings is it was invisible for a month because the broken output lives off-site, nothing in your own build ever went red. Locale drift hides the same way: a missing key or a mangled {count} renders fine locally and only a French user ever sees it.
I built i1n (i1n.ai) for that exact failure mode, i1n check fails CI when any locale drifts from the source (missing keys, broken placeholders). It won't catch an off-site OG bug, but with 164 tools times their locales, are you validating the translated strings in CI today or is it eyeball-on-deploy?
Both today, in that order.
There is a mechanical gate: a linter that checks placeholder parity, charset mixing, CJK punctuation, and a few language-pair traps (zh-CN vocabulary leaking into zh-TW). It runs against the built output as a step in
npm run deploy, so a bad locale blocks the deploy. Post-build rather than pre-merge, which is the gap I'd close next.But the reason it's still eyeball too: the bug class that actually burned us passes every mechanical check. Machine translation half-translates words. We shipped
쇼arpfor "Sharp",생ographiesfor "biographies",Дitherинг, and my favourite, an Indonesian word sitting inside an Arabic string. Placeholders intact, length plausible, grammar fine — and a charset check can't flag them, because it has to exempt Latin characters or it screams on every brand name and acronym. A full audit turned up 25 of those live across two sites.So the model I landed on is two layers: deterministic checks for what code can see, and a review pass by a stronger model for the semantic layer no linter reaches.
The meta-lesson cost more than the bugs, though. Our checker had existed for months but wasn't referenced by any run script, and one version had
쇼arpwritten in its own comment as an example — while that exact string was live in production. A gate that isn't on the execution path isn't a gate.