The translation moment

After article three, translated routes exist and the content index marks every article as isFallback: true for non-English locales. What is still missing is the prose - the actual paragraphs a reader reads. The article body shows a fallback link to the English version because translatedMarkdown is null in the skeleton load function from article three.

This article changes that. The approach: when a translated URL is requested, load the Markdown source through a server-only helper, call the Claude API with only the prose segments, render the result to HTML, and serve the translated page. Article five adds Redis caching so subsequent visitors never trigger an API call.

The Markdown source file is never modified. Code blocks, file paths, and inline code never reach the API. Only the human-readable prose sentences cross the API boundary.

Production note: the earliest version of this flow used direct fs access inside the route and static imports for the translation/rendering modules. The final production version is stricter on both counts: raw post loading is isolated in $lib/server/posts (defined in Step 5 below), and heavy dependencies are loaded dynamically inside the function body so they do not inflate the shared cold-start bundle on Vercel.

Install packages

pnpm add @anthropic-ai/sdk marked

If your project does not already include Node types for server-only modules, add them in the normal way. The final route code in this series no longer relies on direct fs calls, but most SvelteKit projects still keep @types/node available for server code and scripts.

Step 1 - Get your Anthropic API key

The Claude API requires an API key from the Anthropic console. If you do not have one:

  1. Go to console.anthropic.com
  2. Create an account or sign in
  3. Navigate to API Keys in the left sidebar
  4. Click Create Key, give it a name (e.g. hackpile-translation), and copy the value

The key starts with sk-ant-. Store it in .env.local:

# .env.local - never commit this file
ANTHROPIC_API_KEY=sk-ant-api03-...

.env.local is already in .gitignore for SvelteKit projects. Double-check that .env.local appears there before proceeding. The Anthropic SDK reads ANTHROPIC_API_KEY from the environment automatically - you never pass it explicitly in code.

Pricing note: Claude Sonnet 4.6 costs $3 per million input tokens and $15 per million output tokens. A 3,000-word article with 1,800 words of translatable prose uses roughly 2,400 input tokens and 2,200 output tokens, costing about $0.04. Article five’s Redis caching means this cost is paid once per article per locale for complete translations stored permanently, not on every page load.

Step 2 - The prose extractor

Markdown mixes prose and code in a single file. Before sending anything to a translation API, you must cleanly separate them. Sending code blocks to a translation model risks the model “correcting” variable names, translating string literals, or breaking formatted structure.

Create src/lib/i18n/extract.ts:

// src/lib/i18n/extract.ts

export type SegmentType = 'prose' | 'code' | 'frontmatter'

export interface Segment {
	type: SegmentType
	content: string
	// Stable id used for reassembly - matches the keys in the translations Record
	id: string
}

export function extractSegments(markdown: string): Segment[] {
	const segments: Segment[] = []
	let index = 0

	// 1. Separate frontmatter - translated separately via translateFrontmatterValues
	const frontmatterMatch = markdown.match(/^---\n[\s\S]*?\n---\n/)
	let body = markdown

	if (frontmatterMatch) {
		segments.push({ type: 'frontmatter', content: frontmatterMatch[0], id: 'frontmatter' })
		body = markdown.slice(frontmatterMatch[0].length)
	}

	// 2. Split on fenced code blocks - handles both ``` and ~~~ with optional lang
	const parts = body.split(/((?:```|~~~)[\s\S]*?(?:```|~~~))/gm)

	for (const part of parts) {
		if (!part.trim()) continue

		if (part.startsWith('```') || part.startsWith('~~~')) {
			segments.push({ type: 'code', content: part, id: `code-${index++}` })
		} else {
			// 3. Split prose on headings so each heading+paragraph is one unit
			const proseChunks = part.split(/(?=\n#{1,6} )/m)
			for (const chunk of proseChunks) {
				if (!chunk.trim()) continue
				segments.push({ type: 'prose', content: chunk, id: `prose-${index++}` })
			}
		}
	}

	return segments
}

export function reassemble(segments: Segment[], translations: Record<string, string>): string {
	return segments
		.filter((s) => s.type !== 'frontmatter')
		.map((s) => (s.type !== 'prose' ? s.content : (translations[s.id] ?? s.content)))
		.join('\n\n')
}

export function proseOnly(segments: Segment[]): Segment[] {
	return segments.filter((s) => s.type === 'prose')
}

The reassemble fallback - translations[s.id] ?? s.content - means any segment translateBatch fails to return falls back to English rather than disappearing. The page is always complete, even with partial translation results.

Step 3 - The translation module

Create src/lib/i18n/translate.ts. The client uses lazy initialization - getClient() is only called the first time a translation runs. Module-level new Anthropic() would throw at server startup if ANTHROPIC_API_KEY is absent, breaking all routes including cached ones that never translate anything.

The public export is a single function, translateBatch, which handles large articles by splitting prose into chunks of 20 segments, translates the article title and description as synthetic __title__/__description__ segments in the first chunk (saving a round trip), and retries any segments the model drops.

// src/lib/i18n/translate.ts
import Anthropic from '@anthropic-ai/sdk'
import type { Segment } from './extract.js'
import { env as privateEnv } from '$env/dynamic/private'
import { dev } from '$app/environment'

// Dev: Haiku 4.5 (fast, cheap). Prod: Sonnet 4.6 (quality).
// Override with ANTHROPIC_MODEL in .env.local.
const model = (privateEnv.ANTHROPIC_MODEL ??
	(dev ? 'claude-haiku-4-5-20251001' : 'claude-sonnet-4-6')) as string

// Haiku: 4096 output tokens. Sonnet 4+: 8192 output tokens.
const MAX_TOKENS = model.includes('haiku') ? 4096 : 8192
const CHUNK_SIZE = 20 // max prose segments per API call

// Lazy singleton - avoids throwing at module load if the key is absent.
let _client: Anthropic | null = null
function getClient(): Anthropic {
	if (_client) return _client
	const apiKey = privateEnv.ANTHROPIC_API_KEY
	if (!apiKey) throw new Error('[i18n/translate] ANTHROPIC_API_KEY is not set')
	_client = new Anthropic({ apiKey })
	return _client
}

export interface BatchDiagnostics {
	missingIds: string[]
	stopReason: string
	tokenUsage: { input: number; output: number }
	chunkCount: number
}

export interface BatchTranslationResult {
	proseTranslations: Record<string, string>
	title: string
	description: string
	diagnostics: BatchDiagnostics
}

function parseResponse(raw: string): Record<string, string> {
	const out: Record<string, string> = {}
	for (const block of raw.split(/\s*---SEG---\s*/)) {
		const m = block.trim().match(/^\[([^\]]+)\]\s*\n([\s\S]+)/)
		if (!m) continue
		const id = m[1].trim()
		const content = m[2].trim()
		if (id && content) out[id] = content
	}
	return out
}

async function callTranslate(
	segments: Segment[],
	lang: string,
	note?: string
): Promise<{
	parsed: Record<string, string>
	stopReason: string
	usage: { input: number; output: number }
}> {
	const payload = segments.map((s) => `[${s.id}]\n${s.content}`).join('\n\n---SEG---\n\n')
	const res = await getClient().messages.create({
		model,
		max_tokens: MAX_TOKENS,
		messages: [
			{
				role: 'user',
				content: `Translate the following technical Svelte/SvelteKit article segments to ${lang}.

RULES - follow exactly, no explanations:
1. Translate prose only. Never modify content inside triple backtick fences.
2. Never translate inline code in single backticks.
3. Preserve ALL markdown formatting (**, *, ##, >, -, [], etc.).
4. Preserve every [key] marker on its own line - required for reassembly.
5. Return ONLY the translated segments in the exact same format as input.
6. Do NOT merge, skip, or reorder segments.
${note ? `\nNOTE: ${note}` : ''}
SEGMENTS:
${payload}`
			}
		]
	})
	const raw = res.content[0]?.type === 'text' ? res.content[0].text : ''
	return {
		parsed: parseResponse(raw),
		stopReason: res.stop_reason ?? 'unknown',
		usage: { input: res.usage.input_tokens, output: res.usage.output_tokens }
	}
}

// translateBatch is the only public export.
// It chunks prose into groups of CHUNK_SIZE, includes title + description as
// synthetic segments (__title__ / __description__) in the first chunk so they
// are translated with no extra API call, and retries dropped segments once.
export async function translateBatch(
	proseSegments: Segment[],
	title: string,
	description: string,
	lang: string
): Promise<BatchTranslationResult> {
	const metaSegments: Segment[] = [
		{ id: '__title__', type: 'prose', content: title },
		{ id: '__description__', type: 'prose', content: description }
	]

	const chunks: Segment[][] = []
	for (let i = 0; i < proseSegments.length; i += CHUNK_SIZE) {
		chunks.push(proseSegments.slice(i, i + CHUNK_SIZE))
	}
	if (chunks.length === 0) chunks.push([])

	const allParsed: Record<string, string> = {}
	const totalUsage = { input: 0, output: 0 }

	// First chunk: include synthetic title + description segments
	const first = await callTranslate([...metaSegments, ...chunks[0]], lang)
	Object.assign(allParsed, first.parsed)
	totalUsage.input += first.usage.input
	totalUsage.output += first.usage.output
	const firstStopReason = first.stopReason

	for (const chunk of chunks.slice(1)) {
		const r = await callTranslate(chunk, lang)
		Object.assign(allParsed, r.parsed)
		totalUsage.input += r.usage.input
		totalUsage.output += r.usage.output
	}

	// Retry any prose segments the model dropped
	const proseIds = proseSegments.map((s) => s.id)
	const missing = proseIds.filter((id) => !(id in allParsed))
	if (missing.length > 0) {
		try {
			const retry = await callTranslate(
				proseSegments.filter((s) => missing.includes(s.id)),
				lang,
				'These segments were not returned. Translate each one individually without skipping any.'
			)
			Object.assign(allParsed, retry.parsed)
			totalUsage.input += retry.usage.input
			totalUsage.output += retry.usage.output
		} catch {
			// Non-fatal - dropped segments fall back to English via reassemble()
		}
	}

	const translatedTitle = allParsed['__title__'] ?? title
	const translatedDescription = allParsed['__description__'] ?? description
	delete allParsed['__title__']
	delete allParsed['__description__']

	return {
		proseTranslations: allParsed,
		title: translatedTitle,
		description: translatedDescription,
		diagnostics: {
			missingIds: proseIds.filter((id) => !(id in allParsed)),
			stopReason: firstStopReason,
			tokenUsage: totalUsage,
			chunkCount: chunks.length
		}
	}
}

Step 4 - The markdown renderer (marked v15 + Shiki)

Translated prose comes back as a Markdown string. It must be converted to syntax-highlighted HTML before the server returns it. The English articles are compiled at build time by mdsvex - translated articles need a runtime pipeline: marked converts Markdown to HTML, then shiki highlights every code block.

Install Shiki:

pnpm add shiki

marked v15 removed marked.setOptions() as a global mutation. Pass options inline per call. The async: false flag narrows the return type to string (not Promise<string>) so the synchronous internal function stays typesafe.

Note that _renderMarkdown is not exported - external code should always go through renderMarkdownHtml so Shiki highlighting is never accidentally skipped.

// src/lib/i18n/render.ts
import { marked } from 'marked'
import { codeToHtml } from 'shiki'

// Converts heading text to a URL-safe anchor ID.
// e.g. "Step 1 - The Route Matcher" → "step-1-the-route-matcher"
function slugify(text: string): string {
	return text
		.toLowerCase()
		.replace(/[^\w\s-]/g, '') // strip punctuation, keep word chars and hyphens
		.replace(/\s+/g, '-') // spaces → hyphens
		.replace(/-+/g, '-') // collapse consecutive hyphens
		.replace(/^-|-$/g, '') // trim leading/trailing hyphens
}

// Injects id="..." attributes on every <h1>–<h6> element so in-page
// anchors and table-of-contents links work on translated pages.
function addHeadingIds(html: string): string {
	return html.replace(
		/<(h[1-6])>([\/\s\S]*?)<\/\1>/gi,
		(_match: string, tag: string, inner: string) => {
			const text = inner.replace(/<[^>]+>/g, '') // strip nested tags to get plain text
			const id = slugify(text)
			return id ? `<${tag} id="${id}">${inner}</${tag}>` : `<${tag}>${inner}</${tag}>`
		}
	)
}

// Reverses the HTML entity encoding that marked applies inside code blocks.
// Shiki needs the original characters (< > & " ') to tokenise and highlight correctly.
function decodeHtmlEntities(encoded: string): string {
	return encoded
		.replace(/&lt;/g, '<')
		.replace(/&gt;/g, '>')
		.replace(/&amp;/g, '&')
		.replace(/&quot;/g, '"')
		.replace(/&#39;/g, "'")
}

// Internal - do not export. Callers should always use renderMarkdownHtml()
// so Shiki syntax highlighting is never accidentally omitted.
function _renderMarkdown(md: string): string {
	try {
		const html = marked(md, { async: false, gfm: true, breaks: false })
		return addHeadingIds(html)
	} catch {
		return ''
	}
}

// Converts Markdown to Shiki-highlighted HTML.
// This is what gets stored in Redis and injected via {@html} on translated pages.
export async function renderMarkdownHtml(md: string): Promise<string> {
	const base = _renderMarkdown(md)
	const re = /<pre><code class="language-([^"]*)">([\s\S]*?)<\/code><\/pre>/g
	let lastIndex = 0
	let out = ''
	let m: RegExpExecArray | null
	while ((m = re.exec(base)) !== null) {
		out += base.slice(lastIndex, m.index)
		const lang = m[1].toLowerCase()
		const code = decodeHtmlEntities(m[2])
		try {
			out += await codeToHtml(code, { lang, theme: 'tokyo-night' })
		} catch {
			out += m[0]
		}
		lastIndex = re.lastIndex
	}
	out += base.slice(lastIndex)
	return out
}

Because renderMarkdownHtml is async, the +page.server.ts must await it. The HTML string is returned from the server and injected directly via {@html} in the component - no client-side Markdown processing needed.

Step 5 - The server post loader

Before building the load function, create one prerequisite: the server-only helper that reads raw Markdown source. The load function imports it directly, so it needs to exist first.

Create src/lib/server/posts.ts:

// src/lib/server/posts.ts
// Server-only helper for reading raw Markdown source.
// Uses import.meta.glob with ?raw so Vite inlines every post path at build time
// and the result works correctly in both development and production on Vercel.
// Import this from +page.server.ts and +layout.server.ts only - never from
// universal load functions or .svelte components (the browser cannot execute it).

const rawPostModules = import.meta.glob('/src/posts/**/*.md', {
	import: 'default',
	query: '?raw'
})

export async function readPost(slug: string): Promise<string> {
	const postPath = `/src/posts/${slug}.md`
	const loader = rawPostModules[postPath]
	if (!loader) throw new Error(`[server/posts] Article not found: ${postPath}`)
	return (await loader()) as string
}

The ?raw query tells Vite to import each matched file as a plain string - frontmatter included - rather than as a compiled Svelte or mdsvex module. The eager option is left at its default of false, so Vite generates individual lazy loaders rather than bundling all post content eagerly into the server bundle. The actual file content is loaded on demand when a route calls readPost.

This module lives in src/lib/server/ which SvelteKit treats as a server-side boundary. Any import of a $lib/server/ module from a universal load function (+page.ts) or a .svelte component will throw a build error, keeping the raw Markdown source safely server-side.

Step 6 - The complete +page.server.ts load function

This is the production version of the load function, replacing the skeleton from article three. It calls translateBatch (which handles both prose and title/description in one call chain), renders the HTML server-side with renderMarkdownHtml, and returns a translatedHtml string. Article five upgrades this to a streaming Promise and adds the Redis cache layer.

// src/routes/(locale)/[lang=locale]/[...slug]/+page.server.ts
import type { Config } from '@sveltejs/adapter-vercel'
import { error } from '@sveltejs/kit'
import { readPost } from '$lib/server/posts'
import type { PageServerLoad } from './$types'
import { getContent, content } from '$lib/content'
import { getPosition, relatedArticles } from '$lib/content/helpers'
import type { Article } from '$lib/content/types'
import { extractSegments, proseOnly, reassemble } from '$lib/i18n/extract'
import { SUPPORTED_LOCALES } from '$lib/config'
import type { Locale } from '$lib/config'

// ISR with on-demand expiration - article 5 adds setHeaders() with dynamic
// Cache-Control values based on translation completeness.
export const config: Config = {
	isr: { expiration: false }
}

export const load: PageServerLoad = async ({ params, parent }) => {
	const { lang } = await parent()
	const slugPath = params.slug

	if (!SUPPORTED_LOCALES.includes(lang as Locale)) {
		error(400, `Unsupported locale: ${lang}`)
	}

	const englishArticle = content.articles.find((a) => a.slug === slugPath)
	if (!englishArticle) error(404, `Could not find ${slugPath}`)

	const localeContent = getContent(lang)

	let currentPost: Article = { ...englishArticle, lang, isFallback: true }
	let translatedHtml: string | null = null

	let rawMarkdown = ''
	try {
		rawMarkdown = await readPost(slugPath)
	} catch {
		rawMarkdown = ''
	}

	if (rawMarkdown) {
		const segments = extractSegments(rawMarkdown)
		const prose = proseOnly(segments)

		// translateBatch handles prose + title + description in one call chain.
		// It chunks large articles, retries dropped segments automatically, and
		// returns diagnostics (used in article 5's cache layer).
		const { translateBatch } = await import('$lib/i18n/translate')
		const { proseTranslations, title, description } = await translateBatch(
			prose,
			englishArticle.title,
			englishArticle.description,
			lang
		)

		const translatedMarkdown = reassemble(segments, proseTranslations)

		// renderMarkdownHtml runs marked then Shiki - matches production output exactly.
		// Article 5 caches this HTML string in Redis to avoid re-running on every visit.
		const { renderMarkdownHtml } = await import('$lib/i18n/render')
		translatedHtml = await renderMarkdownHtml(translatedMarkdown)

		currentPost = {
			...englishArticle,
			title,
			description,
			lang,
			isFallback: false,
			translationStatus: 'machine-translated'
		}
	}

	const { prev, next } = getPosition({ ...currentPost, slug: slugPath }, localeContent.articles)

	const prerequisites: Article[] = (currentPost.prerequisites ?? [])
		.map((id) => content.articles.find((a) => a.id === id))
		.filter((a): a is Article => a !== undefined)

	let relatedPosts: Article[] = []
	let isSeries = false

	if (currentPost.track?.id && currentPost.topic?.id) {
		isSeries = true
		const trackData = localeContent.tracks[currentPost.track.id]
		relatedPosts = trackData
			? trackData.topics
					.slice()
					.sort((a, b) => a.order - b.order)
					.flatMap((t) => t.articles)
			: []
	} else {
		const curated = (currentPost.related ?? [])
			.map((id) => content.articles.find((a) => a.id === id))
			.filter((a): a is Article => a !== undefined)
		relatedPosts = curated.length > 0 ? curated : relatedArticles(currentPost, content.articles)
	}

	return {
		meta: currentPost,
		translatedHtml, // string | null - article 5 upgrades this to Promise<string | null>
		slugPath,
		prev,
		next,
		prerequisites,
		relatedPosts,
		isSeries,
		currentTopicId: currentPost.topic?.id ?? null,
		lang,
		isFallback: currentPost.isFallback ?? false
	}
}

translateBatch runs title/description translation in the same API call as the first prose chunk - saving one round-trip compared to the two-call approach. Shiki highlighting runs server-side so the component receives ready-to-inject HTML.

Step 7 - The complete +page.svelte component

This is the final form of the translated article page component, replacing the phase-one stub from article three. The server now returns translatedHtml: string | null (Shiki-highlighted HTML). The component uses it directly - no client-side Markdown processing, no $derived rendering.

Article five upgrades translatedHtml to a Promise<string | null>, changes the component to use {#await data.translatedHtml} with a loading skeleton so the page shell renders immediately on a cache miss, and adds the Redis cache layer. For now, the load function awaits the translation before returning, so translatedHtml is a resolved string.

<!-- src/routes/(locale)/[lang=locale]/[...slug]/+page.svelte -->
<script lang="ts">
	import type { PageData } from './$types'
	import ArticleMeta from '$lib/components/article/ArticleMeta.svelte'
	import ProgressBar from '$lib/components/article/ProgressBar.svelte'

	let { data }: { data: PageData } = $props()
	// No client-side renderMarkdown - the server returns ready-to-inject HTML.
</script>

<svelte:head>
	<title>{data.meta.seo?.title ?? data.meta.title}</title>
	<meta name="description" content={data.meta.seo?.description ?? data.meta.description} />
	<meta property="og:title" content={data.meta.seo?.title ?? data.meta.title} />
	<meta property="og:description" content={data.meta.seo?.description ?? data.meta.description} />
	<meta property="og:type" content="article" />
</svelte:head>

<article class="article-page">
	{#if data.isFallback}
		<div class="fallback-notice" role="status" aria-live="polite">
			<span aria-hidden="true">🌐</span>
			<p>
				This article is not yet translated into
				<strong>{data.lang.toUpperCase()}</strong>. You are reading the English original.
			</p>
		</div>
	{/if}

	{#if data.meta.translationStatus === 'machine-translated' && !data.isFallback}
		<div class="translation-notice" role="status">
			<span aria-hidden="true">🤖</span>
			<p>Machine-translated. Technical terms and code are preserved in English.</p>
		</div>
	{/if}

	<ArticleMeta post={data.meta} />

	{#if data.meta.position && (data.meta.position.total ?? 1) > 1}
		<ProgressBar
			index={data.meta.position.index ?? data.meta.article?.order ?? 1}
			total={data.meta.position.total ?? 1}
			trackType={data.meta.track?.type ?? 'reference'}
		/>
	{/if}

	{#if data.prerequisites.length > 0}
		<aside class="prerequisites" aria-label="Prerequisites">
			<h2>Before you read this</h2>
			<ul>
				{#each data.prerequisites as prereq (prereq.id ?? prereq.slug)}
					<li>
						<a href="/{data.lang}/{prereq.slug}">{prereq.title}</a>
					</li>
				{/each}
			</ul>
		</aside>
	{/if}

	{#if data.translatedHtml}
		<div class="prose">
			<!-- eslint-disable-next-line svelte/no-at-html-tags -->
			{@html data.translatedHtml}
		</div>
	{:else}
		<div class="fallback-content">
			<p>
				Translation not yet available.
				<a href="/{data.slugPath}">Read the English version →</a>
			</p>
		</div>
	{/if}

	<!-- Series navigation - locale-prefixed hrefs constructed inline.
       The existing SeriesNav component builds /{slug} hrefs for English routes.
       Translated routes need /{lang}/{slug} - constructed here to avoid
       modifying a shared component. -->
	{#if data.prev || data.next}
		<nav class="series-nav" aria-label="Series navigation">
			{#if data.prev}
				<a href="/{data.lang}/{data.prev.slug}" class="series-nav-link series-nav-prev" rel="prev">
					<span class="nav-label">← Previous</span>
					<span class="nav-title">{data.prev.title}</span>
				</a>
			{/if}
			{#if data.next}
				<a href="/{data.lang}/{data.next.slug}" class="series-nav-link series-nav-next" rel="next">
					<span class="nav-label">Next →</span>
					<span class="nav-title">{data.next.title}</span>
				</a>
			{/if}
		</nav>
	{/if}

	{#if data.relatedPosts.length > 0}
		<aside class="related-posts" aria-label="Related articles">
			<h2>Related articles</h2>
			<ul>
				{#each data.relatedPosts.slice(0, 4) as post (post.id ?? post.slug)}
					<li>
						<a href="/{data.lang}/{post.slug}">{post.title}</a>
					</li>
				{/each}
			</ul>
		</aside>
	{/if}
</article>

The {@html} directive renders pre-built HTML from the server. The content is safe because it originates from your own Markdown source files processed through the Claude API - it never comes from user input.

When not to use on-demand translation

On-demand translation has one significant drawback: the first visitor to an untranslated page waits for the API call to complete. For a 3,000-word article with 1,800 words of prose, this is typically 3 to 6 seconds with Claude Sonnet 4.6. That is acceptable for the long tail of articles that receive occasional visits in a given locale - but unacceptable for high-traffic entry points.

Use the pre-translation scripts (article six) for pillar articles (pillar: true), series entry points (article.order === 1), and articles linked from the homepage. Use on-demand translation for everything else. Article five adds the Redis caching layer - solving the only remaining problem with this approach.


Key takeaways

  • ANTHROPIC_API_KEY must be set in .env.local and never committed - it is injected via $env/dynamic/private, not process.env
  • The Anthropic client uses a lazy singleton (getClient()) - module-level new Anthropic() would throw at server startup for routes that never need translation
  • Segment.id is the stable key used throughout extract, translate, and reassemble - do not confuse with the old key field name from early tutorials
  • translateBatch replaces the old two-function approach (translateSegments + translateFrontmatterValues) - it handles chunking, title/description, and retry in one call chain
  • renderMarkdownHtml is the only exported render function - _renderMarkdown is internal; callers must always go through renderMarkdownHtml to get Shiki highlighting
  • Use a server-only readPost() helper (defined in Step 5) for raw Markdown source - backed by import.meta.glob with ?raw so it works identically in dev and on Vercel
  • Keep translation and rendering modules behind await import(...) so Shiki and the Anthropic SDK do not inflate the shared cold-start bundle
  • The server returns translatedHtml: string | null - no client-side Markdown processing; {@html data.translatedHtml} is safe because it comes from your own source files
  • marked v15 requires inline options ({ async: false, gfm: true }) - marked.setOptions() was removed in v15
  • Article five adds Redis caching, streaming (translatedHtml: Promise<string | null>), and dynamic Cache-Control headers

Further reading