mirror of
https://github.com/hanzoai/i18n.git
synced 2026-10-30 14:24:32 +00:00
Add 43 locales to the registry, their loaders and catalogs: Persian, Dari, Hebrew, Urdu, Pashto, Central Kurdish, Sindhi, Uyghur, Punjabi in both scripts, four regional Arabics, Hong Kong Chinese, Mexican and Argentine Spanish, European Portuguese, Canadian French, and 24 more. 34 wait for a native reader in review.json; ti, qu, gn, ay, bo and dv are not shipped. Every locale in Arabic or Hebrew script is rtl, and the registry test derives the script from the tag. rtl.css selects on :dir(rtl), so it holds inside a right-to-left region of a left-to-right page. resolve no longer leans on registry order: after the exact tag and the script, a request takes its own region, then the variant that serves it (CLDR parents for es-419 and pt-PT, Macau to zh-HK, the Arabic dialect areas), then the variant with no region, then the language's own region. pnb reaches pa-PK.
127 lines
5.8 KiB
TypeScript
127 lines
5.8 KiB
TypeScript
import assert from 'node:assert/strict'
|
|
import { test } from 'node:test'
|
|
|
|
import { SUPPORTED_LOCALES } from '../src/index.ts'
|
|
import { alternates, links, sitemap, type Site } from '../src/alternate.ts'
|
|
|
|
const site: Site = { origin: 'https://hanzo.ai' }
|
|
const few: Site = { origin: 'https://hanzo.ai', locales: ['en-US', 'ja-JP'] }
|
|
|
|
test('every locale is listed, and so is x-default', () => {
|
|
const all = alternates('/pricing', site)
|
|
assert.equal(all.length, SUPPORTED_LOCALES.length + 1)
|
|
assert.deepEqual(all.slice(0, -1).map((a) => a.hreflang), [...SUPPORTED_LOCALES])
|
|
assert.deepEqual(all.find((a) => a.hreflang === 'zh-Hant'), { hreflang: 'zh-Hant', href: 'https://hanzo.ai/zh-Hant/pricing' })
|
|
assert.deepEqual(all.at(-1), { hreflang: 'x-default', href: 'https://hanzo.ai/en-US/pricing' })
|
|
})
|
|
|
|
test('x-default points at a page that exists', () => {
|
|
// A site shipping a subset that excludes our default would otherwise send
|
|
// every unmatched reader to a URL it does not serve.
|
|
const jp = alternates('/pricing', { origin: 'https://hanzo.ai', locales: ['ja-JP', 'ko-KR'] })
|
|
assert.deepEqual(jp.at(-1), { hreflang: 'x-default', href: 'https://hanzo.ai/ja-JP/pricing' })
|
|
assert.deepEqual(alternates('/pricing', { origin: 'https://hanzo.ai', locales: [] }), [])
|
|
})
|
|
|
|
test('a path is a path however it was written', () => {
|
|
const href = (path: string) => alternates(path, few)[0].href
|
|
assert.equal(href('/about'), 'https://hanzo.ai/en-US/about')
|
|
assert.equal(href('about'), 'https://hanzo.ai/en-US/about')
|
|
assert.equal(href('/about/'), 'https://hanzo.ai/en-US/about')
|
|
// The root contributes nothing, or every home page advertises a trailing
|
|
// slash the site then redirects away from — one crawl wasted per locale.
|
|
assert.equal(href('/'), 'https://hanzo.ai/en-US')
|
|
assert.equal(href(''), 'https://hanzo.ai/en-US')
|
|
assert.equal(alternates('/x', { origin: 'https://hanzo.ai/', locales: ['en-US'] })[0].href, 'https://hanzo.ai/en-US/x')
|
|
})
|
|
|
|
test('a site may keep its locales anywhere', () => {
|
|
// Serving the default unprefixed is the common shape and must not need a
|
|
// second implementation of any of this.
|
|
const bare: Site = {
|
|
origin: 'https://hanzo.ai',
|
|
locales: ['en-US', 'de-DE'],
|
|
url: (locale, path) => (locale === 'en-US' ? `https://hanzo.ai${path}` : `https://de.hanzo.ai${path}`),
|
|
}
|
|
assert.deepEqual(alternates('/about', bare), [
|
|
{ hreflang: 'en-US', href: 'https://hanzo.ai/about' },
|
|
{ hreflang: 'de-DE', href: 'https://de.hanzo.ai/about' },
|
|
{ hreflang: 'x-default', href: 'https://hanzo.ai/about' },
|
|
])
|
|
})
|
|
|
|
test('head tags are ready to paste', () => {
|
|
assert.equal(
|
|
links('/about', few),
|
|
[
|
|
'<link rel="alternate" hreflang="en-US" href="https://hanzo.ai/en-US/about"/>',
|
|
'<link rel="alternate" hreflang="ja-JP" href="https://hanzo.ai/ja-JP/about"/>',
|
|
'<link rel="alternate" hreflang="x-default" href="https://hanzo.ai/en-US/about"/>',
|
|
].join('\n'),
|
|
)
|
|
})
|
|
|
|
test('every locale gets its own entry carrying the whole set', () => {
|
|
// The set counts only when it is reciprocal. One entry for the default URL
|
|
// listing the translations reads as a claim nothing confirms, and the
|
|
// translations go unindexed.
|
|
const xml = sitemap(['/about'], few)
|
|
assert.equal(count(xml, '<url>'), 2)
|
|
assert.ok(xml.includes('<loc>https://hanzo.ai/en-US/about</loc>'))
|
|
assert.ok(xml.includes('<loc>https://hanzo.ai/ja-JP/about</loc>'))
|
|
// 2 entries x (2 locales + x-default)
|
|
assert.equal(count(xml, '<xhtml:link'), 6)
|
|
assert.equal(count(xml, 'hreflang="x-default"'), 2)
|
|
})
|
|
|
|
test('a sitemap covers every path', () => {
|
|
const xml = sitemap(['/', '/about', '/pricing'], few)
|
|
assert.equal(count(xml, '<url>'), 6)
|
|
assert.ok(xml.includes('<loc>https://hanzo.ai/ja-JP</loc>'))
|
|
})
|
|
|
|
test('a URL cannot break the document that carries it', () => {
|
|
// A second query parameter is all it takes. Unescaped, the sitemap is not XML
|
|
// and every locale in it is dropped.
|
|
const xml = sitemap(['/s?q=a&sort=<b>'], { origin: 'https://hanzo.ai', locales: ['en-US'] })
|
|
assert.ok(xml.includes('<loc>https://hanzo.ai/en-US/s?q=a&sort=<b></loc>'))
|
|
assert.ok(!xml.includes('&sort'))
|
|
// Escaping the ampersand anywhere but first escapes the escapes.
|
|
assert.ok(!xml.includes('&lt;'))
|
|
assert.ok(!xml.includes('&amp;'))
|
|
})
|
|
|
|
test('a sitemap is a document, not a fragment', () => {
|
|
const xml = sitemap(['/'], few)
|
|
assert.ok(xml.startsWith('<?xml version="1.0" encoding="UTF-8"?>\n<urlset '))
|
|
assert.ok(xml.includes('xmlns:xhtml="http://www.w3.org/1999/xhtml"'))
|
|
assert.ok(xml.endsWith('</urlset>\n'))
|
|
assert.deepEqual(sitemap([], few).match(/<url>/g), null)
|
|
})
|
|
|
|
function count(text: string, needle: string): number {
|
|
return text.split(needle).length - 1
|
|
}
|
|
|
|
|
|
test('a sitemap too large to be accepted is refused, not written', () => {
|
|
// One <url> per locale is what makes the set reciprocal and is also what
|
|
// multiplies the file: 4,000 paths across 79 locales is 316,000 URLs, past the
|
|
// 50,000 a sitemap may hold. Over the limit the file is rejected WHOLE — the
|
|
// failure is no index at all, visible only in Search Console — so the call
|
|
// says so here instead.
|
|
const many = Array.from({ length: 4000 }, (_, i) => `/p${i}`)
|
|
assert.throws(() => sitemap(many, { origin: 'https://hanzo.ai' }), /50000|split/)
|
|
|
|
// The ordinary case is untouched.
|
|
assert.ok(sitemap(['/', '/pricing'], { origin: 'https://hanzo.ai' }).includes('<urlset'))
|
|
})
|
|
|
|
test('a control character cannot break the document', () => {
|
|
// C0 controls have no XML escape at all, so one byte out of a CMS slug makes
|
|
// the whole sitemap unparseable rather than one URL wrong.
|
|
const xml = sitemap(['/a\u0001b'], { origin: 'https://hanzo.ai' })
|
|
assert.ok(!/[\u0000-\u0008\u000B\u000C\u000E-\u001F]/.test(xml), 'no raw control byte survives')
|
|
assert.ok(xml.includes('/ab'), 'the rest of the path is kept')
|
|
})
|