diff --git a/apps/web/src/app/[slug]/read/page.jsx b/apps/web/src/app/[slug]/read/page.jsx index fda8a828..6e46f45c 100644 --- a/apps/web/src/app/[slug]/read/page.jsx +++ b/apps/web/src/app/[slug]/read/page.jsx @@ -363,6 +363,18 @@ export default async function ReaderPage({ params, searchParams }) {

)} + {/* + * The translation is running in the background (c0mpute takes minutes + * under load). The original shows meanwhile; reloading later is a + * cache hit and costs the reader nothing more. + */} + {'pending' in attempt && attempt.pending && ( +

+ Translating this post — it takes a few minutes. Showing the original until it is + ready; reload this page later to read it in your language. +

+ )} + =18.0.0" }, "optionalPeers": ["react"] }, "sha512-hXzHgCFj5PAHsov6bnIai++cRaHJsoo8OsSPj1b41qDAP3WfABE87O3A7vlQ44MoRPkqV7pEk5JaSgSAGmB9Lw=="], diff --git a/packages/translate/index.js b/packages/translate/index.js index 23db3655..274c9407 100644 --- a/packages/translate/index.js +++ b/packages/translate/index.js @@ -14,13 +14,41 @@ export { } from './src/languages.js'; export { MODEL, + MAX_PEER_ARTICLE_CHARS, MAX_SOURCE_CHARS, MAX_ARTICLE_CHARS, MIN_ARTICLE_CHARS, translatePost, translateArticle, + readTagged, } from './src/translate.js'; +/** + * How long a request waits for a fresh translation before answering "pending". + * + * c0mpute, which answers first, takes minutes under load (a peer node writes a + * few tokens a second, after a queue). That cannot sit inside a page render or + * behind the proxy's timeout, so the translation runs in the background and the + * request only waits this long for it. A fast answer still lands on the first + * click; a slow one lands in the cache, and the next view of the post is a hit. + */ +export const DEFAULT_WAIT_MS = 8_000; + +/** + * Background translations one server process runs at once. Past this a cache + * miss shows the original and is not charged, rather than queueing work that + * would finish long after anybody cared. + */ +export const MAX_PENDING = 24; + +/** @type {Map>} */ +const pending = new Map(); + +/** For tests: the background translations still running. */ +export function pendingTranslations() { + return [...pending.values()]; +} + /** * Paid translations one reader may trigger in a UTC day. * @@ -60,13 +88,15 @@ export const DEFAULT_DAILY_TOTAL = 1000; * hurry. A value that is not a positive number falls back to the default rather * than to "no limit" — a typo in a Railway variable must not uncap spending. * - * @returns {{ perUser: number, total: number }} + * @returns {{ perUser: number, total: number, waitMs: number }} */ export function limits() { const env = process.env; + const wait = Number(env['TRANSLATE_WAIT_MS']); return { perUser: positive(env['TRANSLATE_DAILY_PER_USER'], DEFAULT_DAILY_PER_USER), total: positive(env['TRANSLATE_DAILY_TOTAL'], DEFAULT_DAILY_TOTAL), + waitMs: Number.isFinite(wait) && wait >= 0 ? wait : DEFAULT_WAIT_MS, }; } @@ -108,6 +138,7 @@ function positive(raw, fallback) { * sourceLang?: string|null, * userId?: string|null, * now?: Date, + * ai?: import('@profullstack/ai-providers').AiRouter, * }} input * @returns {Promise<{ * translation: { @@ -115,6 +146,7 @@ function positive(raw, fallback) { * truncated: boolean, sourceLang: string|null, cached: boolean * }|null, * limited: boolean, + * pending?: boolean, * }>} */ export async function ensureTranslation(db, input) { @@ -151,8 +183,14 @@ export async function ensureTranslation(db, input) { const userId = input.userId ? String(input.userId) : null; if (!userId) return none(); + // Already being translated (by this process): say so, and do not charge the + // reader again for asking twice. + const key = `${input.itemId}\u0000${target}`; + if (pending.has(key)) return { translation: null, limited: false, pending: true }; + if (pending.size >= MAX_PENDING) return none(); + const day = translations.usageDay(input.now); - const { perUser, total } = limits(); + const { perUser, total, waitMs } = limits(); const [mine, everyone] = await Promise.all([ translations.usageForUser(db, userId, day), @@ -174,58 +212,80 @@ export async function ensureTranslation(db, input) { const body = String(input.contentHtml ?? ''); const wholeArticle = textLength(body) >= MIN_ARTICLE_CHARS; - let fresh; - try { - fresh = wholeArticle - ? await translateArticle({ - title: input.title, - summary: input.summary, - contentHtml: body, - targetLang: target, - sourceLang: source, - }) - : await translatePost({ - title: input.title, - summary: input.summary, - targetLang: target, - sourceLang: source, - }); - } catch { - // Rate limits, timeouts, a revoked key: all of them mean "show the - // original", and none of them should reach the reader as an error page. - return none(); - } + const job = (async () => { + let fresh; + try { + fresh = wholeArticle + ? await translateArticle({ + title: input.title, + summary: input.summary, + contentHtml: body, + targetLang: target, + sourceLang: source, + ai: input.ai, + }) + : await translatePost({ + title: input.title, + summary: input.summary, + targetLang: target, + sourceLang: source, + ai: input.ai, + }); + } catch { + // Rate limits, timeouts, a revoked key: all of them mean "show the + // original", and none of them should reach the reader as an error page. + return null; + } - if (!fresh) return none(); - - // Sanitized before it is stored as well as before it is rendered. The model - // is not a trusted author — a post can ask it for a script tag — and a - // sanitizer that only runs at render time is one forgotten call site away - // from serving whatever came back. - const translatedHtml = fresh.contentHtml ? sanitizeHtml(fresh.contentHtml) : null; - - await translations.saveTranslation(db, { - itemId: input.itemId, - lang: target, - title: fresh.title, - summary: fresh.summary, - contentHtml: translatedHtml, - truncated: Boolean(fresh.truncated), - model: fresh.model, - sourceLang: fresh.sourceLang ?? source, - }); + if (!fresh) return null; - return { - translation: { + // Sanitized before it is stored as well as before it is rendered. The model + // is not a trusted author — a post can ask it for a script tag — and a + // sanitizer that only runs at render time is one forgotten call site away + // from serving whatever came back. + const translatedHtml = fresh.contentHtml ? sanitizeHtml(fresh.contentHtml) : null; + + await translations.saveTranslation(db, { + itemId: input.itemId, + lang: target, + title: fresh.title, + summary: fresh.summary, + contentHtml: translatedHtml, + truncated: Boolean(fresh.truncated), + model: fresh.model, + sourceLang: fresh.sourceLang ?? source, + }); + + return { title: fresh.title, summary: fresh.summary, contentHtml: translatedHtml, truncated: Boolean(fresh.truncated), sourceLang: fresh.sourceLang ?? source, cached: false, - }, - limited: false, - }; + }; + })() + .catch((err) => { + console.warn(`[translate] ${input.itemId} -> ${target}: ${err?.message ?? err}`); + return null; + }) + .finally(() => pending.delete(key)); + pending.set(key, job); + + // Wait a little: a quick answer is shown now, a slow one finishes in the + // background and is a cache hit on the next view. + let timer; + const waited = await Promise.race([ + job.then((translation) => ({ translation })), + new Promise((resolve) => { + timer = setTimeout(() => resolve(null), waitMs); + }), + ]); + clearTimeout(timer); + + if (!waited) return { translation: null, limited: false, pending: true }; + if (!waited.translation) return none(); + return { translation: waited.translation, limited: false }; } /** diff --git a/packages/translate/package.json b/packages/translate/package.json index 08848a81..0c00e98f 100644 --- a/packages/translate/package.json +++ b/packages/translate/package.json @@ -13,6 +13,7 @@ }, "dependencies": { "@anthropic-ai/sdk": "^1.3.0", + "@profullstack/ai-providers": "0.2.0", "@rssamplifier/db": "workspace:*", "@rssamplifier/feed": "workspace:*" } diff --git a/packages/translate/src/translate.js b/packages/translate/src/translate.js index 294fa406..0fe55e65 100644 --- a/packages/translate/src/translate.js +++ b/packages/translate/src/translate.js @@ -1,4 +1,5 @@ import Anthropic from '@anthropic-ai/sdk'; +import { AllProvidersFailedError, ProviderError, getAi } from '@profullstack/ai-providers'; /** * Machine translation of one post's title and summary. @@ -7,11 +8,32 @@ import Anthropic from '@anthropic-ai/sdk'; * task where a small model loses almost nothing — there is no reasoning to do, * the source text says exactly what the output should say — and the directory * holds 47k feeds, so the per-post cost is the whole design constraint. + * + * c0mpute (the peer network, through @profullstack/ai-providers) answers + * first. The Anthropic call below, with its schema-constrained output, is the + * paid fallback and runs only when c0mpute fails and a key is set. Any other + * paid provider the router has (OpenAI, when OPENAI_API_KEY is set) gets the + * same tagged-text prompt c0mpute does. + * + * Everything translated here is a public feed post, which is why it may go to + * peer nodes at all. */ -/** @see https://platform.claude.com/docs/en/about-claude/models/overview */ +/** The paid fallback's model. @see https://platform.claude.com/docs/en/about-claude/models/overview */ export const MODEL = 'claude-haiku-4-5'; +/** + * Longest article body sent to c0mpute, in characters of HTML. + * + * A peer node writes a few tokens a second under load, so 60k characters would + * be hours. Past this the article is translated down to here and marked + * truncated, exactly as the paid path does at MAX_ARTICLE_CHARS. + */ +export const MAX_PEER_ARTICLE_CHARS = 8_000; + +/** Whole-call ceiling for a c0mpute translation; they run off the request path. */ +const PEER_TIMEOUT_MS = 30 * 60_000; + /** * Longest source text sent for translation. * @@ -65,6 +87,7 @@ const SYSTEM = [ * targetLang: string, * sourceLang?: string|null, * client?: Anthropic, + * ai?: import('@profullstack/ai-providers').AiRouter, * }} input * @returns {Promise<{ title: string, summary: string|null, sourceLang: string|null, model: string }|null>} */ @@ -72,9 +95,6 @@ export async function translatePost(input) { const title = String(input.title ?? '').trim(); if (!title) return null; - const client = input.client ?? anthropic(); - if (!client) return null; - const summary = String(input.summary ?? '') .trim() .slice(0, MAX_SOURCE_CHARS); @@ -83,6 +103,38 @@ export async function translatePost(input) { ? `The source is believed to be in "${input.sourceLang}", but trust the text over that label.` : 'The source language is not recorded; work it out from the text.'; + const user = [ + `Target language: ${input.targetLang}`, + known, + '', + `\n${title}\n`, + `\n${summary}\n`, + ].join('\n'); + + return route(input, { + feature: 'translate-post', + paid: (client) => postViaAnthropic(client, user), + request: { + system: `${SYSTEM}\n\n${TAGGED_ANSWER}`, + user, + maxTokens: 2000, + temperature: 0.2, + timeoutMs: PEER_TIMEOUT_MS, + }, + read: (text) => { + const parsed = readTagged(text, ['title', 'summary', 'source_language']); + return parsed && finishPost(parsed); + }, + }); +} + +/** + * The paid tier: Anthropic with structured outputs. + * + * @param {Anthropic} client + * @param {string} user + */ +async function postViaAnthropic(client, user) { const response = await client.messages.create({ model: MODEL, // Title plus a capped summary — a few thousand characters of output at @@ -90,18 +142,7 @@ export async function translatePost(input) { max_tokens: 4000, system: SYSTEM, output_config: { format: { type: 'json_schema', schema: OUTPUT_SCHEMA } }, - messages: [ - { - role: 'user', - content: [ - `Target language: ${input.targetLang}`, - known, - '', - `\n${title}\n`, - `\n${summary}\n`, - ].join('\n'), - }, - ], + messages: [{ role: 'user', content: user }], }); // A refusal and a truncation both come back as an ordinary 200 with unusable @@ -119,6 +160,14 @@ export async function translatePost(input) { return null; } + const result = finishPost(parsed); + return result && { ...result, model: MODEL }; +} + +/** + * @param {{ title?: unknown, summary?: unknown, source_language?: unknown }} parsed + */ +function finishPost(parsed) { const translatedTitle = String(parsed.title ?? '').trim(); if (!translatedTitle) return null; @@ -127,8 +176,7 @@ export async function translatePost(input) { return { title: translatedTitle, summary: translatedSummary || null, - sourceLang: parsed.source_language ? String(parsed.source_language) : null, - model: MODEL, + sourceLang: parsed.source_language ? String(parsed.source_language).trim() : null, }; } @@ -210,6 +258,7 @@ const ARTICLE_SYSTEM = [ * targetLang: string, * sourceLang?: string|null, * client?: Anthropic, + * ai?: import('@profullstack/ai-providers').AiRouter, * }} input * @returns {Promise<{ * title: string, summary: string|null, contentHtml: string, @@ -221,9 +270,6 @@ export async function translateArticle(input) { const body = String(input.contentHtml ?? '').trim(); if (!title || !body) return null; - const client = input.client ?? anthropic(); - if (!client) return null; - const truncated = body.length > MAX_ARTICLE_CHARS; const source = truncated ? body.slice(0, MAX_ARTICLE_CHARS) : body; @@ -235,27 +281,59 @@ export async function translateArticle(input) { ? `The source is believed to be in "${input.sourceLang}", but trust the text over that label.` : 'The source language is not recorded; work it out from the text.'; + const prompt = (/** @type {string} */ body, /** @type {boolean} */ cut) => + [ + `Target language: ${input.targetLang}`, + known, + cut ? 'This is the beginning of a longer post; translate what is here and stop.' : '', + '', + `\n${title}\n`, + `\n${summary}\n`, + `\n${body}\n`, + ].join('\n'); + + // The peer network gets less of a long post than the paid model would: it is + // a few tokens a second, and a translation nobody waits for is no use. + const peerCut = body.length > MAX_PEER_ARTICLE_CHARS; + const peerSource = peerCut ? cutHtml(body, MAX_PEER_ARTICLE_CHARS) : body; + + return route(input, { + feature: 'translate-article', + paid: (client) => articleViaAnthropic(client, prompt(source, truncated), truncated), + request: { + system: `${ARTICLE_SYSTEM}\n\n${TAGGED_ARTICLE_ANSWER}`, + user: prompt(peerSource, peerCut), + maxTokens: 8000, + temperature: 0.2, + timeoutMs: PEER_TIMEOUT_MS, + }, + read: (text) => { + const parsed = readTagged(text, ['title', 'summary', 'source_language', 'body']); + if (!parsed) return null; + const result = finishArticle({ ...parsed, content_html: parsed.body }); + return result && { ...result, truncated: peerCut }; + }, + }); +} + +/** + * The paid tier: Anthropic, streamed, with structured outputs. + * + * Streamed rather than awaited as one response. A full article at this token + * ceiling is minutes of generation, and a non-streaming request that long hits + * the SDK's HTTP timeout and throws away work that was nearly finished. + * + * @param {Anthropic} client + * @param {string} user + * @param {boolean} truncated + */ +async function articleViaAnthropic(client, user, truncated) { const stream = client.messages.stream({ model: MODEL, max_tokens: ARTICLE_MAX_TOKENS, system: ARTICLE_SYSTEM, output_config: { format: { type: 'json_schema', schema: ARTICLE_SCHEMA } }, - messages: [ - { - role: 'user', - content: [ - `Target language: ${input.targetLang}`, - known, - truncated - ? 'This is the beginning of a longer post; translate what is here and stop.' - : '', - '', - `\n${title}\n`, - `\n${summary}\n`, - `\n${source}\n`, - ].join('\n'), - }, - ], + messages: [{ role: 'user', content: user }], }); const response = await stream.finalMessage(); @@ -277,6 +355,14 @@ export async function translateArticle(input) { return null; } + const result = finishArticle(parsed); + return result && { ...result, model: MODEL, truncated }; +} + +/** + * @param {{ title?: unknown, summary?: unknown, content_html?: unknown, source_language?: unknown }} parsed + */ +function finishArticle(parsed) { const translatedTitle = String(parsed.title ?? '').trim(); const translatedBody = String(parsed.content_html ?? '').trim(); if (!translatedTitle || !translatedBody) return null; @@ -287,12 +373,126 @@ export async function translateArticle(input) { title: translatedTitle, summary: translatedSummary || null, contentHtml: translatedBody, - sourceLang: parsed.source_language ? String(parsed.source_language) : null, - model: MODEL, - truncated, + sourceLang: parsed.source_language ? String(parsed.source_language).trim() : null, }; } +/** + * The answer format for c0mpute and any non-Anthropic fallback. Tags rather + * than JSON: a 9B model escapes a page of HTML into a JSON string badly, and + * it copies the tags the input already arrives in very reliably. + */ +const TAGGED_ANSWER = [ + 'Answer in exactly this format and nothing else:', + 'the translated title', + 'the translated summary', + 'ISO-639-1 code of the source text', +].join('\n'); + +const TAGGED_ARTICLE_ANSWER = [ + TAGGED_ANSWER, + 'the translated body HTML', +].join('\n'); + +/** + * Reads `...` fields out of a model answer. `body` is read last and + * greedily, so markup inside it cannot end it early; the short fields are read + * from the text before it. Null if any field is missing. + * + * @param {string} text + * @param {string[]} fields + * @returns {Record|null} + */ +export function readTagged(text, fields) { + let rest = String(text ?? '').replace(/[\s\S]*?<\/think>/gi, ''); + /** @type {Record} */ + const out = {}; + if (fields.includes('body')) { + const open = rest.indexOf(''); + const close = rest.lastIndexOf(''); + if (open < 0 || close < open) return null; + out.body = rest.slice(open + 6, close).trim(); + rest = rest.slice(0, open); + } + for (const field of fields) { + if (field === 'body') continue; + const m = new RegExp(`<${field}>([\\s\\S]*?)`, 'i').exec(rest); + if (!m) return null; + out[field] = m[1].trim(); + } + return out; +} + +/** + * Cuts HTML at a paragraph-ish boundary at or before `max` characters, so the + * model is not handed half a tag. + * + * @param {string} html + * @param {number} max + */ +function cutHtml(html, max) { + const head = html.slice(0, max); + const at = Math.max(head.lastIndexOf('

'), head.lastIndexOf(''), head.lastIndexOf('')); + if (at > max / 2) return head.slice(0, head.indexOf('>', at) + 1); + const tag = head.lastIndexOf('<'); + return tag > head.lastIndexOf('>') ? head.slice(0, tag) : head; +} + +/** + * Runs one translation through the provider chain: c0mpute first (streamed, + * over the package's provider.stream), then the paid fallbacks. The Anthropic + * slot runs this module's own SDK call. + * + * A test (or a caller) that passes `client` without `ai` gets that client and + * nothing else, which is how the request shape is unit-tested. + * + * Null when every provider failed or answered unusably: a translation is an + * enhancement and its absence is never an error page. + * + * @template T + * @param {{ client?: any, ai?: any }} input + * @param {{ + * feature: string, + * paid: (client: Anthropic) => Promise, + * request: object, + * read: (text: string) => T|null, + * }} job + * @returns {Promise<(T & { model: string })|null>} + */ +async function route(input, job) { + if (input.client && !input.ai) return /** @type {any} */ (job.paid(input.client)); + + const router = input.ai ?? getAi(); + try { + const { value } = await router.run( + async (/** @type {any} */ provider) => { + if (provider.name === 'anthropic') { + const client = input.client ?? anthropic(); + if (!client) throw new ProviderError('anthropic', 'no API key', { kind: 'billing' }); + const answer = await job.paid(client); + if (!answer) throw new ProviderError('anthropic', 'refused or unusable answer', { kind: 'output' }); + return answer; + } + const out = + typeof provider.stream === 'function' + ? await provider.stream(job.request, () => {}) + : await provider.generate(job.request); + const answer = job.read(out.text); + if (!answer) throw new ProviderError(provider.name, 'answer was not in the tagged format', { kind: 'output' }); + return { ...answer, model: `${provider.name}:${out.model}` }; + }, + { feature: job.feature }, + ); + return value; + } catch (err) { + if (err instanceof AllProvidersFailedError) { + console.warn(`[translate] ${job.feature}: ${err.message}`); + return null; + } + throw err; + } +} + /** * A client, or null when the deployment has no key. * diff --git a/packages/translate/test/ensure.test.js b/packages/translate/test/ensure.test.js index ef059e71..35d7b31f 100644 --- a/packages/translate/test/ensure.test.js +++ b/packages/translate/test/ensure.test.js @@ -3,7 +3,19 @@ import { test, before, after, beforeEach } from 'node:test'; import { q, translations, accounts } from '@rssamplifier/db'; import { connectTest } from '@rssamplifier/db/src/testdb.js'; -import { DEFAULT_DAILY_PER_USER, DEFAULT_DAILY_TOTAL, ensureTranslation, limits } from '../index.js'; +import { AiRouter } from '@profullstack/ai-providers'; + +import { + DEFAULT_DAILY_PER_USER, + DEFAULT_DAILY_TOTAL, + DEFAULT_WAIT_MS, + ensureTranslation, + limits, + pendingTranslations, +} from '../index.js'; + +// No provider at all unless a test hands one in: c0mpute off, no paid keys. +process.env['C0MPUTE_DISABLED'] = '1'; /** * The branches of ensureTranslation that must never reach the API: a cache hit, @@ -44,6 +56,7 @@ beforeEach(async () => { await db.execute('delete from translation_usage'); delete process.env['TRANSLATE_DAILY_PER_USER']; delete process.env['TRANSLATE_DAILY_TOTAL']; + delete process.env['TRANSLATE_WAIT_MS']; }); after(async () => { @@ -199,7 +212,11 @@ test("yesterday's spending does not count against today", async () => { }); test('limits fall back to the defaults rather than to no limit', async () => { - assert.deepEqual(limits(), { perUser: DEFAULT_DAILY_PER_USER, total: DEFAULT_DAILY_TOTAL }); + assert.deepEqual(limits(), { + perUser: DEFAULT_DAILY_PER_USER, + total: DEFAULT_DAILY_TOTAL, + waitMs: DEFAULT_WAIT_MS, + }); // A typo in a Railway variable must not uncap spending. for (const bad of ['', 'lots', '0', '-5', 'NaN']) { @@ -210,3 +227,78 @@ test('limits fall back to the defaults rather than to no limit', async () => { process.env['TRANSLATE_DAILY_TOTAL'] = '250'; assert.equal(limits().total, 250); }); + +/** + * A c0mpute stand-in that answers in the tagged format after `ms`. + * + * @param {number} ms + */ +function peer(ms) { + const provider = { + name: 'c0mpute', + calls: 0, + /** @param {any} req */ + async generate(req) { + provider.calls += 1; + provider.last = req; + await new Promise((r) => setTimeout(r, ms)); + return { + text: 'Configurer le BIOS UEFI de Proxmox\nBonjour le forum !\nde', + model: 'qwen-9b', + usage: { inputTokens: 1, outputTokens: 1, estimated: true }, + }; + }, + last: /** @type {any} */ (null), + }; + return provider; +} + +test('c0mpute answers first, and a quick answer is shown and cached on the first click', async () => { + await db.execute('delete from item_translations'); + const provider = peer(5); + const { translation, limited } = await ask({ ai: new AiRouter({ primary: [provider] }) }); + + assert.equal(limited, false); + assert.equal(translation.title, 'Configurer le BIOS UEFI de Proxmox'); + assert.equal(translation.summary, 'Bonjour le forum !'); + assert.match(provider.last.system, //); + + const row = await translations.translationFor(db, itemId, 'fr'); + assert.equal(row.model, 'c0mpute:qwen-9b'); +}); + +test('a slow translation runs in the background: pending now, a cache hit later, charged once', async () => { + await db.execute('delete from item_translations'); + process.env['TRANSLATE_WAIT_MS'] = '10'; + const provider = peer(150); + const ai = new AiRouter({ primary: [provider] }); + + const first = await ask({ ai }); + assert.equal(first.translation, null); + assert.equal(first.pending, true); + + // Asking again while it runs is not a second call and not a second charge. + const second = await ask({ ai }); + assert.equal(second.pending, true); + assert.equal(provider.calls, 1); + assert.equal(await translations.usageForUser(db, userId, translations.usageDay()), 1); + + await Promise.all(pendingTranslations()); + const third = await ask({ ai }); + assert.equal(third.translation.cached, true); + assert.equal(third.translation.title, 'Configurer le BIOS UEFI de Proxmox'); +}); + +test('an answer that is not in the tagged format shows the original', async () => { + await db.execute('delete from item_translations'); + const junk = { + name: 'c0mpute', + async generate() { + return { text: 'Sure! Here is your translation: ...', model: 'm', usage: { inputTokens: 0, outputTokens: 0, estimated: true } }; + }, + }; + const { translation, pending } = await ask({ ai: new AiRouter({ primary: [junk] }) }); + assert.equal(translation, null); + assert.equal(pending, undefined); + assert.equal(await translations.translationFor(db, itemId, 'fr'), null); +}); diff --git a/packages/translate/test/translate.test.js b/packages/translate/test/translate.test.js index 0336ae5e..bf5d5f49 100644 --- a/packages/translate/test/translate.test.js +++ b/packages/translate/test/translate.test.js @@ -182,3 +182,22 @@ test('a post with no title never reaches the API', async () => { ); assert.equal(client.calls.length, 0); }); + +test('readTagged reads the short fields and a body full of markup', async () => { + const { readTagged } = await import('../src/translate.js'); + const text = [ + 'scratch', + 'Titre', + '', + 'en', + '

Un texte

</p>
', + ].join('\n'); + assert.deepEqual(readTagged(text, ['title', 'summary', 'source_language', 'body']), { + title: 'Titre', + summary: '', + source_language: 'en', + body: '

Un texte

</p>
', + }); + assert.equal(readTagged('x', ['title', 'summary']), null); + assert.equal(readTagged('x

cut off', ['title', 'body']), null); +});