Translate a blog post or long content
A blog post is not forbidden. It is simply not one translation — it is one translation per block.
The server refuses a source string longer than 2000 characters. That is not a storage limit — a row is keyed by a hash of its source text, so the text itself could be any length. It is a quality limit: one translation request has to stay one small job that a human can check. So you do not send the post. You send its blocks.
Blocks are also the better unit for three reasons that have nothing to do with the limit:
- You fix a typo in one paragraph, you pay for one paragraph. The whole post as one key means every edit re-translates every word of it.
- A translation stays good. A model asked for 2000 words in one call drifts, drops a list, or reformats a table. One block is one small, verifiable job.
- The cache works. The intro you reuse on ten posts is translated once.
The recipe
- Split the post into blocks. One block is one Markdown unit: a heading, a paragraph, a whole list, a quote, a table row. Never split in the middle of a sentence.
- Keep the Markdown inside the block.
**bold**,[a link](https://example.com),`code`and list markers stay in the string. The translation keeps them, and your renderer restores the styling. See With markdown. - Give every block of the post the same
context: a very short summary of the post. One sentence, 200 characters maximum. A lone paragraph does not say what the article is about; the summary does, and that is what makes the translation of that paragraph right. - Use one
namespaceper post. A reader then downloads the post they open, and nothing else. See Namespaces. - Do not send code fences. Render them as they are.
- Aim for 1000 characters per block. 2000 is the hard reject. A normal paragraph runs 300 to 800 characters, so the number almost never decides anything — it is the guard for the paragraph that ran away. Cut that one at a sentence break. Never cut a table or a list to obey the number: keep the structure whole, even at 1500 characters.
Keep the summary stable. The context is part of the row identity: if you rewrite the summary, every block of the post becomes a new row, and every block is translated and billed again.
Split the document
/**
* One entry per Markdown block. Code fences are kept whole and never translated.
* A real parser (remark) is better; this is enough for a normal post.
*/
function splitBlocks(markdown: string): { text: string; translate: boolean }[] {
const blocks: { text: string; translate: boolean }[] = [];
let fence: string[] | null = null;
for (const part of markdown.split(/\n{2,}/)) {
const opensOrCloses = (part.match(/```/g) ?? []).length % 2 === 1;
if (fence) {
fence.push(part);
if (opensOrCloses) {
blocks.push({ text: fence.join('\n\n'), translate: false });
fence = null;
}
continue;
}
if (opensOrCloses) {
fence = [part];
continue;
}
const text = part.trim();
if (text) blocks.push({ text, translate: !text.startsWith('```') });
}
return blocks;
}
Translate the blocks
- React (web)
- React Native
- Node.js
import { useTranslation } from 'i18n-keyless-react';
import ReactMarkdown from 'react-markdown';
// One sentence, 200 characters maximum, the same for every block of this post.
const SUMMARY = 'Guide: cache API responses at the edge with a Cloudflare Worker.';
function Block({ text, slug }: { text: string; slug: string }) {
const translated = useTranslation(text, { context: SUMMARY, namespace: `blog:${slug}` });
return <ReactMarkdown key={translated}>{translated}</ReactMarkdown>;
}
export function Post({ markdown, slug }: { markdown: string; slug: string }) {
return (
<article>
{splitBlocks(markdown).map((block, index) =>
block.translate ? (
<Block key={index} text={block.text} slug={slug} />
) : (
<ReactMarkdown key={index}>{block.text}</ReactMarkdown>
),
)}
</article>
);
}
import { useTranslation } from 'i18n-keyless-react';
import MarkdownDisplay from 'react-native-markdown-display';
const SUMMARY = 'Guide: cache API responses at the edge with a Cloudflare Worker.';
function Block({ text, slug }: { text: string; slug: string }) {
const translated = useTranslation(text, { context: SUMMARY, namespace: `blog:${slug}` });
return <MarkdownDisplay>{translated}</MarkdownDisplay>;
}
awaitForTranslationOrFallbackToOriginal NEED to be awaited, to prevent 429 to happen.
Translate the blocks one after the other, then join them back with a blank line. The result is a Markdown document again.
import { awaitForTranslationOrFallbackToOriginal, type Lang } from 'i18n-keyless-node';
const SUMMARY = 'Guide: cache API responses at the edge with a Cloudflare Worker.';
async function translatePost(markdown: string, slug: string, lang: Lang): Promise<string> {
const out: string[] = [];
for (const block of splitBlocks(markdown)) {
if (!block.translate) {
out.push(block.text);
continue;
}
out.push(
await awaitForTranslationOrFallbackToOriginal(block.text, lang, {
context: SUMMARY,
namespace: `blog:${slug}`,
}),
);
}
return out.join('\n\n');
}
The metadata is blocks too
The title, the description, the excerpt and every image alt are short strings. Translate each one with the same context and the same namespace as the body. The description is what a search engine shows, so it must be translated, and it must be translated knowing the subject.
Rules for an AI agent
- A post is a list of Markdown blocks, one translation per block. Never one call for the whole post.
context= one short sentence that summarises the post. Identical for every block. 200 characters maximum. Never change it after publication.namespace= one per post, for exampleblog:<slug>.- Keep the Markdown syntax inside each block. Do not strip it, do not convert it to HTML.
- Never translate a code fence, a URL, or a front-matter key.
- Target 1000 characters per block. Split a longer paragraph at a sentence break. Keep a table or a list whole whatever its length. Over 2000 characters the server answers
400.