also looking at this
fix: translate fragment navigation titles
#2120
2 files
+76
-0
Review threads live on the whole diff, not on one commit, so none are shown here - a thread's line means something in the branch's final form, and painting it into an intermediate step would put it on code it is not about.
| @@ -3748,6 +3748,15 @@ function __stxOverlay(errs){ | ||
| 3748 | 3748 | html = ensureRouterConfig(html) |
| 3749 | 3749 | |
| 3750 | 3750 | const headers = new Headers(res.headers) |
| 3751 | const fragmentTitle = headers.get('X-STX-Title') | |
| 3752 | if (res.headers.get('X-STX-Fragment') === 'true' && fragmentTitle && i18nConfig && stx.applyTranslations) { | |
| 3753 | // The fragment's title was captured before this final translation pass. | |
| 3754 | // Treat it as HTML text while translating, then return plain title text | |
| 3755 | // to the router. Escaping first preserves literal entities in the title. | |
| 3756 | const titleHtml = escapeHtml(decodeURIComponent(fragmentTitle)) | |
| 3757 | const translated = stx.applyTranslations(titleHtml, i18nConfig, locale) | |
| 3758 | headers.set('X-STX-Title', encodeURIComponent(decodeTitleEntities(translated))) | |
| 3759 | } | |
| 3751 | 3760 | headers.delete('content-length') |
| 3752 | 3761 | return new Response(html, { |
| 3753 | 3762 | status: res.status, |
| @@ -0,0 +1,67 @@ | ||
| 1 | import { afterAll, beforeAll, expect, test } from 'bun:test' | |
| 2 | import { rm } from 'node:fs/promises' | |
| 3 | import path from 'node:path' | |
| 4 | import { createTempDir } from '../../stx/src/temp-dir' | |
| 5 | import { freePort } from '../../stx/test-utils/test-port' | |
| 6 | ||
| 7 | const port = freePort() | |
| 8 | const base = `http://127.0.0.1:${port}` | |
| 9 | const serveSource = path.join(import.meta.dir, '../src/serve.ts') | |
| 10 | let directory: string | |
| 11 | let child: ReturnType<typeof Bun.spawn> | undefined | |
| 12 | let output: Promise<string> | undefined | |
| 13 | let errors: Promise<string> | undefined | |
| 14 | ||
| 15 | beforeAll(async () => { | |
| 16 | directory = createTempDir('stx-fragment-title-i18n-') | |
| 17 | await Bun.write(path.join(directory, 'views/page.stx'), `<!DOCTYPE html> | |
| 18 | <html><head><title>{t:page.title} | 100% & &lt;</title></head> | |
| 19 | <body><main><h1>{t:page.title}</h1></main></body></html>`) | |
| 20 | await Bun.write(path.join(directory, 'views/literal.stx'), `<!DOCTYPE html> | |
| 21 | <html><head><title>Literal &lt; 100%</title></head><body><main>Literal</main></body></html>`) | |
| 22 | await Bun.write(path.join(directory, 'driver.ts'), `import { serve } from ${JSON.stringify(serveSource)} | |
| 23 | await serve({ patterns: ['views'], port: ${port}, imageWarmup: false, | |
| 24 | i18n: { locales: ['en', 'de', 'nl'], defaultLocale: 'en', labels: {}, pickerSelector: '#lang-picker', | |
| 25 | translations: { en: { 'page.title': 'English & "quoted" <literal> 你好' }, de: { 'page.title': 'Deutsch & "zitiert" <literal> Grüß' } } } | |
| 26 | })`) | |
| 27 | child = Bun.spawn([process.execPath, 'driver.ts'], { cwd: directory, stdout: 'pipe', stderr: 'pipe' }) | |
| 28 | output = new Response(child.stdout).text() | |
| 29 | errors = new Response(child.stderr).text() | |
| 30 | const deadline = Date.now() + 30_000 | |
| 31 | while (true) { | |
| 32 | try { await fetch(`${base}/not-a-page`); break } | |
| 33 | catch { | |
| 34 | if (Date.now() > deadline) throw new Error('Title regression server did not start') | |
| 35 | await Bun.sleep(50) | |
| 36 | } | |
| 37 | } | |
| 38 | }, 35_000) | |
| 39 | ||
| 40 | afterAll(async () => { | |
| 41 | child?.kill() | |
| 42 | if (child) await child.exited | |
| 43 | await output | |
| 44 | await errors | |
| 45 | if (directory) await rm(directory, { recursive: true, force: true }) | |
| 46 | }) | |
| 47 | ||
| 48 | for (const [route, expected] of [ | |
| 49 | ['/page', 'English & "quoted" <literal> 你好 | 100% & <'], | |
| 50 | ['/de/page', 'Deutsch & "zitiert" <literal> Grüß | 100% & <'], | |
| 51 | ['/nl/page', 'English & "quoted" <literal> 你好 | 100% & <'], | |
| 52 | ['/literal', 'Literal < 100%'], | |
| 53 | ]) { | |
| 54 | test(`fragment title uses the destination locale and preserves text: ${route}`, async () => { | |
| 55 | const full = await fetch(`${base}${route}`) | |
| 56 | expect(full.status).toBe(200) | |
| 57 | const html = await full.text() | |
| 58 | expect(html).not.toContain('{t:page.title}') | |
| 59 | const fragment = await fetch(`${base}${route}`, { headers: { 'X-STX-Router': 'true' } }) | |
| 60 | expect(fragment.status).toBe(200) | |
| 61 | expect(fragment.headers.get('X-STX-Fragment')).toBe('true') | |
| 62 | const title = fragment.headers.get('X-STX-Title') | |
| 63 | expect(title).not.toBeNull() | |
| 64 | expect(decodeURIComponent(title!)).toBe(expected) | |
| 65 | expect(await fragment.text()).not.toContain('{t:page.title}') | |
| 66 | }) | |
| 67 | } | |