diff --git a/packages/sitetile/astro/src/components/ArchiveView.astro b/packages/sitetile/astro/src/components/ArchiveView.astro index 5c9bee5..59bafb2 100644 --- a/packages/sitetile/astro/src/components/ArchiveView.astro +++ b/packages/sitetile/astro/src/components/ArchiveView.astro @@ -76,7 +76,7 @@ const archive = showSidebar ? groupByArchive(corpus) : []; {dateBadgeParts(p.date, meta.lang).year} ) : ( - + ))}
{p.description || p.excerpt}
} diff --git a/packages/sitetile/astro/src/components/BlogIndexView.astro b/packages/sitetile/astro/src/components/BlogIndexView.astro index 20733bd..5e94c4d 100644 --- a/packages/sitetile/astro/src/components/BlogIndexView.astro +++ b/packages/sitetile/astro/src/components/BlogIndexView.astro @@ -89,7 +89,7 @@ const title = isHome {dateBadgeParts(p.date, meta.lang).year} ) : ( - + ))}{p.description || p.excerpt}
} diff --git a/packages/sitetile/astro/src/components/PostArticle.astro b/packages/sitetile/astro/src/components/PostArticle.astro index 00b2920..8610cd8 100644 --- a/packages/sitetile/astro/src/components/PostArticle.astro +++ b/packages/sitetile/astro/src/components/PostArticle.astro @@ -13,7 +13,9 @@ import { safeHref } from '@sitetile'; const { post, meta, newer, older, keepReadingCards, relatedCards } = Astro.props; // blog-date-format (home meta): cjk-badge falls back to cjk-full's full string here — -// the three-span badge layout is index-cards-only (see blog.mjs fmtDate()). +// the three-span badge layout is index-cards-only (see blog.mjs fmtDate()). Absent/unrecognised +// (cjk is an alias of cjk-full) falls to fmtDate's own locale default, which is why this page's +// own `meta.lang` — not the site default — is what gets passed to it below. const dateFormat = meta['blog-date-format']; // blog-post-linebreaks: br → WP-wpautop mode (single newline inside a paragraph →{p.description}
} @@ -160,7 +162,7 @@ const crumbBlog = String(meta['blog-title'] || 'Blog').trim();{p.description}
} diff --git a/packages/sitetile/astro/src/lib/blog.mjs b/packages/sitetile/astro/src/lib/blog.mjs index 2c776e7..cf5d9f6 100644 --- a/packages/sitetile/astro/src/lib/blog.mjs +++ b/packages/sitetile/astro/src/lib/blog.mjs @@ -8,9 +8,10 @@ import { splitFrontmatter, bodyHtml, inlineHtml, parseSite, safeHref, safeSrc } // it. Re-exported here because every component already imports these from blog.mjs — the seam // moved, the call sites did not. export { sidebarCopy, unquote, archivePrefixes, dateBadgeParts } from './chrome-copy.mjs'; -// toUrlLocale is a pure string transform (no @sitetile import), so pulling it in here costs -// nothing a plain `node` test can't already afford — sitemap.mjs already does the same cross-import. -import { toUrlLocale } from '../packages/lingo/locale.mjs'; +// toUrlLocale / toBcp47 are pure string transforms (no @sitetile import), so pulling them in here +// costs nothing a plain `node` test can't already afford — sitemap.mjs already does the same +// cross-import. +import { toUrlLocale, toBcp47 } from '../packages/lingo/locale.mjs'; const FM_LIST_RE = /^\[(.*)\]$/; const PRIVACY_FRONTMATTER = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/; @@ -496,14 +497,78 @@ export function groupByArchive(posts) { })); } -// fmtDate: format a pubDate string per the `blog-date-format` meta key (home.md -// frontmatter). Default (format is undefined/unrecognized) = the historic -// en-US "June 25, 2026" — kept byte-identical to the original inline arrow fn so -// existing output never changes when the meta key is absent. -export function fmtDate(s, format) { +// The `blog-date-format` values fmtDate recognises — named here once so the unknown-value warning +// can quote the exact list rather than a copy of it going stale next to the switch below. +const KNOWN_DATE_FORMATS = ['ymd-slash', 'cjk', 'cjk-full', 'cjk-badge', 'cjk-md']; + +// `cjk-full` / `cjk-badge` / `cjk-md` (and the `cjk` alias) are a script, not a punctuation style — +// an owner who writes `blog-date-format: cjk` is choosing that layout for the pages that read in a +// CJK script. Ruling (2026-09-11, github.com/CVERInc/tile#34 round 1): these formats apply to the +// PAGE's own locale when that locale is Japanese, Korean, or Chinese, and fall back to that page's +// own Intl default everywhere else — an English page on the same multilingual site keeps reading +// "July 13, 2024", not "2024 年 7 月 13 日". `ymd-slash` carries no script, so it is exempt from this +// gate and always applies, on every locale, as before. +const CJK_DATE_FORMATS = new Set(['cjk-full', 'cjk-badge', 'cjk-md']); +// Primary BCP-47 subtag test, done on the page's OWN raw `lang:` value (whatever an author wrote — +// `ja-JP`, `zh-Hant`, `zh-TW`, bare `zh`…) rather than routing it through toBcp47() first, so a +// tag toBcp47 cannot canonicalise (an unrecognised or malformed value) still gets a straight answer +// here instead of silently reading as non-CJK. +function isCJKLang(lang) { + const primary = String(lang || '').toLowerCase().split(/[-_]/)[0]; + return primary === 'ja' || primary === 'ko' || primary === 'zh'; +} + +// The shared "no named format" renderer: the page's own locale via Intl, falling back to en-US +// when no locale is known (byte-identical historic output) AND when the locale IS known but is not +// a tag Intl accepts — a site's `lang:` is free text (a typo, an underscore instead of a hyphen, a +// value nobody ever validated against BCP-47), and `new Intl.DateTimeFormat()` throws a RangeError +// on anything it cannot parse rather than degrading gracefully. Before this guard that RangeError +// crashed the whole build on the very first date it tried to render (github.com/CVERInc/tile#34 +// review round 1, P1-1) — the safe fallback is worse-looking dates, never a build that cannot ship. +function localeDefaultDate(d, lang) { + const opts = { year: 'numeric', month: 'long', day: 'numeric' }; + const tag = lang ? toBcp47(lang) : 'en-US'; + try { + return new Intl.DateTimeFormat(tag, opts).format(d); + } catch { + return new Intl.DateTimeFormat('en-US', opts).format(d); + } +} + +// A build renders every post's date once per locale, so an unrecognised `blog-date-format` would +// otherwise warn hundreds of times for the one config line that is wrong. Keyed by the raw value, +// not a single flag, so a site that (mis)configures two different bad values still hears about +// both — module-scoped state, reset per build (a fresh process / fresh import). +const warnedDateFormats = new Set(); +function warnUnknownDateFormat(format) { + if (format == null || format === '' || warnedDateFormats.has(format)) return; + warnedDateFormats.add(format); + console.warn(`[sitetile] Unrecognised blog-date-format "${format}" — expected one of: ${KNOWN_DATE_FORMATS.join(', ')}. Falling back to the page's own language.`); +} + +// fmtDate: format a pubDate string per the `blog-date-format` meta key (home.md frontmatter). +// `lang` is THIS PAGE's own locale (the Lingo variant being rendered — metaL.lang on a translated +// page, the site default elsewhere), never the site's default language on a page rendering a +// different one. Default (format is undefined/unrecognised) formats with that locale via Intl, so +// a ja-JP page reads "2024年7月13日" and an en-US one "July 13, 2024" — no locale known (lang +// absent) keeps the historic literal en-US output byte-identical, and an unparseable lang tag +// (RangeError from Intl) degrades to that same en-US output rather than crashing the build. +// `cjk` is accepted as an alias of `cjk-full` (the value people reach for); any other unrecognised +// value falls to the locale default AND names the allowed values in a build-time warning, once per +// build per bad value — a site whose config looks right and silently does nothing was the bug. +// An explicit CJK format (`cjk`/`cjk-full`/`cjk-badge`/`cjk-md`) is scoped to CJK page locales +// (ja / zh-* / ko, see isCJKLang) — a non-CJK page locale renders ITS OWN locale default instead, +// same as an unrecognised format would (see CJK_DATE_FORMATS above). `lang` absent keeps the +// historic behaviour of always rendering the named format, since there is no page locale to gate +// on. `ymd-slash` has no script and is never gated. +export function fmtDate(s, format, lang) { const d = new Date(s); if (isNaN(d)) return s; - switch (format) { + const fmt = format === 'cjk' ? 'cjk-full' : format; + if (CJK_DATE_FORMATS.has(fmt) && lang && !isCJKLang(lang)) { + return localeDefaultDate(d, lang); + } + switch (fmt) { case 'ymd-slash': // 2026/05/20 return `${d.getFullYear()}/${String(d.getMonth() + 1).padStart(2, '0')}/${String(d.getDate()).padStart(2, '0')}`; case 'cjk-full': // 2025 年 11 月 11 日 @@ -512,7 +577,8 @@ export function fmtDate(s, format) { case 'cjk-md': // 4月8日 return `${d.getMonth() + 1}月${d.getDate()}日`; default: - return d.toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' }); + warnUnknownDateFormat(fmt); + return localeDefaultDate(d, lang); } } diff --git a/packages/sitetile/astro/src/pages/search/label/[tag]/index.astro b/packages/sitetile/astro/src/pages/search/label/[tag]/index.astro index c4668de..af8e631 100644 --- a/packages/sitetile/astro/src/pages/search/label/[tag]/index.astro +++ b/packages/sitetile/astro/src/pages/search/label/[tag]/index.astro @@ -109,7 +109,7 @@ const pageHref = (n) => (n === 1 ? `/search/label/${tag}` : `/search/label/${tag {dateBadgeParts(p.date, meta.lang).year} ) : ( - + ))}{p.description || p.excerpt}
} diff --git a/packages/sitetile/astro/src/pages/search/label/[tag]/page/[n].astro b/packages/sitetile/astro/src/pages/search/label/[tag]/page/[n].astro index 23ba23f..6a6f5c5 100644 --- a/packages/sitetile/astro/src/pages/search/label/[tag]/page/[n].astro +++ b/packages/sitetile/astro/src/pages/search/label/[tag]/page/[n].astro @@ -91,7 +91,7 @@ const pageHref = (n) => (n === 1 ? `/search/label/${tag}` : `/search/label/${tag {dateBadgeParts(p.date, meta.lang).year} ) : ( - + ))}{p.description || p.excerpt}
} diff --git a/packages/sitetile/blog-date-format.test.mjs b/packages/sitetile/blog-date-format.test.mjs new file mode 100644 index 0000000..f867aea --- /dev/null +++ b/packages/sitetile/blog-date-format.test.mjs @@ -0,0 +1,159 @@ +// fmtDate must speak the PAGE's own language, not the site's default one. +// run: node packages/sitetile/blog-date-format.test.mjs (wired into scripts/test.sh) +// +// THE GAP (github.com/CVERInc/tile#34, a live Lingo + Blog site): fmtDate(s, format) took no +// locale at all, so its "no format / unrecognised format" branch was a hardcoded +// `d.toLocaleDateString('en-US', …)` — every post on every locale of a multilingual site read the +// same English date, and a site whose `blog-date-format: cjk` looked right and did nothing, +// because `cjk` was never one of the recognised values and the miss was silent. +// +// fmtDate now takes the PAGE's own locale (the Lingo variant actually being rendered) as its third +// argument and formats the default branch with it via Intl; `cjk` is accepted as an alias of +// `cjk-full`; an unrecognised value still renders (the locale default, never a blank date) but +// warns once per build, naming the values it does recognise. + +// P3-2 (review round 1): `new Date('2024-07-13')` is UTC midnight, so formatting it in a +// west-of-UTC zone (e.g. America/Los_Angeles) reads back as the previous day — a pre-existing gap +// this suite's date literals would otherwise hit for any contributor whose machine (or CI runner) +// is not UTC. Pinned here, in this process only (each suite file is its own `node` invocation — +// see scripts/test.sh), rather than switching every literal to a datetime with a fixed offset. +process.env.TZ = 'UTC'; + +import assert from 'node:assert/strict'; +import { registerHooks } from 'node:module'; +import { join, dirname } from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; + +const HERE = dirname(fileURLToPath(import.meta.url)); + +// blog.mjs imports the model layer as `@sitetile`, an Astro build alias plain node cannot resolve — +// same seam blog-unlisted.test.mjs and blog-locale-archives.test.mjs already teach the resolver. +registerHooks({ + resolve(spec, ctx, next) { + if (spec === '@sitetile') { + return { url: pathToFileURL(join(HERE, 'site-core.js')).href, shortCircuit: true }; + } + return next(spec, ctx); + }, +}); + +const { fmtDate } = await import('./astro/src/lib/blog.mjs'); + +let passed = 0; +function test(name, fn) { + try { fn(); passed++; console.log(' ✓ ' + name); } + catch (e) { console.error(' ✗ ' + name + '\n ' + (e && e.message ? e.message : e)); process.exitCode = 1; } +} + +function withCapturedWarnings(fn) { + const original = console.warn; + const calls = []; + console.warn = (...args) => calls.push(args.join(' ')); + try { fn(); } finally { console.warn = original; } + return calls; +} + +const DATE = '2024-07-13'; +const INTL_OPTS = { year: 'numeric', month: 'long', day: 'numeric' }; +// Expected strings come from Intl itself, computed here with the SAME api fmtDate's default +// branch uses — this pins BEHAVIOUR, not a hand-copied table of locale data that could drift +// from what the ICU build under test actually produces. +const intlExpected = (bcp47) => new Intl.DateTimeFormat(bcp47, INTL_OPTS).format(new Date(DATE)); + +test('no format set → the PAGE locale (the Lingo variant being rendered), not the site default', () => { + assert.equal(fmtDate(DATE, undefined, 'ja-JP'), intlExpected('ja')); + assert.equal(fmtDate(DATE, undefined, 'zh-TW'), intlExpected('zh-Hant')); + assert.equal(fmtDate(DATE, undefined, 'en-US'), intlExpected('en')); + // P3-1 (review round 1): the three assertions above compare fmtDate against Intl's OWN output, + // which proves fmtDate called Intl correctly but not that this runner's ICU data actually HAS + // Japanese — a small-ICU or system-ICU build missing `ja` silently answers in English and this + // test would still be green. Pin one literal so a build lacking full ICU fails loudly here + // instead of shipping English dates on a Japanese page. + assert.equal(fmtDate(DATE, undefined, 'ja-JP'), '2024年7月13日', 'ICU probe: this runner must have Japanese locale data'); +}); + +test('no format AND no locale known → the historic en-US literal, byte-identical', () => { + assert.equal(fmtDate(DATE, undefined), 'July 13, 2024'); + assert.equal(fmtDate(DATE), 'July 13, 2024'); +}); + +test('cjk is accepted as an alias of cjk-full', () => { + assert.equal(fmtDate(DATE, 'cjk'), fmtDate(DATE, 'cjk-full')); + assert.equal(fmtDate(DATE, 'cjk'), '2024 年 7 月 13 日'); + // the alias resolves before the CJK-locale gate below, so the two stay equal on every lang — + // including a non-CJK one, where both fall back to that locale's own default together. + assert.equal(fmtDate(DATE, 'cjk', 'en-US'), fmtDate(DATE, 'cjk-full', 'en-US')); +}); + +test('P2-1 (ruling 2026-09-11): an explicit CJK format applies to CJK page locales, not every locale', () => { + // ja-JP + cjk-full → CJK, unchanged. + assert.equal(fmtDate(DATE, 'cjk-full', 'ja-JP'), '2024 年 7 月 13 日'); + // en-US + cjk-full → this page's OWN locale default, never the CJK literal. + assert.equal(fmtDate(DATE, 'cjk-full', 'en-US'), 'July 13, 2024'); + // zh-TW / ko-KR are CJK page locales too. + assert.equal(fmtDate(DATE, 'cjk-badge', 'zh-TW'), '2024 年 7 月 13 日'); + assert.equal(fmtDate(DATE, 'cjk-md', 'ko-KR'), '7月13日'); + // ymd-slash is script-neutral and always applies, CJK page locale or not. + assert.equal(fmtDate('2026-05-20', 'ymd-slash', 'zh-TW'), '2026/05/20'); + assert.equal(fmtDate('2026-05-20', 'ymd-slash', 'en-US'), '2026/05/20'); +}); + +test('an unrecognised blog-date-format renders the locale default, and warns once per build — naming the allowed values', () => { + const calls = withCapturedWarnings(() => { + fmtDate(DATE, 'cjk-legacy-typo', 'ja-JP'); // post 1 of a build sharing one bad site config + fmtDate(DATE, 'cjk-legacy-typo', 'ja-JP'); // post 2 + fmtDate(DATE, 'cjk-legacy-typo', 'ja-JP'); // post 3 + }); + assert.equal(calls.length, 1, 'one warning for the whole build, not one per post'); + assert.match(calls[0], /cjk-legacy-typo/, 'names the value the site actually set'); + for (const known of ['ymd-slash', 'cjk', 'cjk-full', 'cjk-badge', 'cjk-md']) { + assert.ok(calls[0].includes(known), `warning text must name the allowed value "${known}"`); + } + assert.equal(fmtDate(DATE, 'cjk-legacy-typo', 'ja-JP'), intlExpected('ja'), + 'the post itself still renders the locale default — an unrecognised config never blanks a date'); +}); + +test('P3-5: every KNOWN_DATE_FORMATS value renders without warning', () => { + // KNOWN_DATE_FORMATS (blog.mjs) is a hand-copied list next to the switch it names, kept only for + // the warning text — nothing else checked the two agree. A value dropped from the switch but + // left in the list would still warn here on every real one, which is the one drift that matters + // (the list naming a format that no longer renders); a value added to the switch but left off + // the list only makes the warning text stale, not the guarantee this test makes. + for (const known of ['ymd-slash', 'cjk', 'cjk-full', 'cjk-badge', 'cjk-md']) { + const calls = withCapturedWarnings(() => fmtDate(DATE, known, 'ja-JP')); + assert.equal(calls.length, 0, `"${known}" is a KNOWN format and must not warn`); + } +}); + +test('every existing explicit blog-date-format renders byte-identical to before', () => { + // no lang given at all → no page locale to gate a CJK format on, so it still always applies, + // exactly as before this format gained locale-awareness. + assert.equal(fmtDate('2026-05-20', 'ymd-slash'), '2026/05/20'); + assert.equal(fmtDate('2025-11-11', 'cjk-full'), '2025 年 11 月 11 日'); + assert.equal(fmtDate('2025-11-11', 'cjk-badge'), '2025 年 11 月 11 日'); + assert.equal(fmtDate('2026-04-08', 'cjk-md'), '4月8日'); + // ymd-slash is script-neutral and never gated, on any lang. + assert.equal(fmtDate('2026-05-20', 'ymd-slash', 'ja-JP'), '2026/05/20'); +}); + +test('an unparseable date string is returned as-is, regardless of lang', () => { + assert.equal(fmtDate('nonsense', undefined, 'ja-JP'), 'nonsense'); +}); + +test('P1-1 (review round 1): an invalid BCP-47 lang tag falls back to en-US instead of crashing the build', () => { + // `Intl.DateTimeFormat` throws RangeError on a tag it cannot parse (underscores instead of + // hyphens, a bare Japanese word, a space) — this must never propagate out of fmtDate. + for (const badTag of ['ja_JP', 'zh_TW', 'en US', '日本語', 'not-a-locale-!!']) { + assert.doesNotThrow(() => fmtDate(DATE, undefined, badTag), `fmtDate must not throw for lang=${JSON.stringify(badTag)}`); + assert.equal(fmtDate(DATE, undefined, badTag), 'July 13, 2024', `bad tag ${JSON.stringify(badTag)} falls back to the historic en-US output`); + } + // an empty tag is falsy, so it already takes the "no locale known" path — pinned here too since + // it is the review's other named case. + assert.doesNotThrow(() => fmtDate(DATE, undefined, '')); + assert.equal(fmtDate(DATE, undefined, ''), 'July 13, 2024'); + // the guard also protects a CJK format's locale-default fallback (a non-CJK bad tag). + assert.doesNotThrow(() => fmtDate(DATE, 'cjk-full', 'en_US')); + assert.equal(fmtDate(DATE, 'cjk-full', 'en_US'), 'July 13, 2024'); +}); + +console.log(`\n${passed} passed`); diff --git a/packages/sitetile/chrome-copy.test.mjs b/packages/sitetile/chrome-copy.test.mjs index 61ff68c..f8728a7 100644 --- a/packages/sitetile/chrome-copy.test.mjs +++ b/packages/sitetile/chrome-copy.test.mjs @@ -18,15 +18,29 @@ // months because a full-width colon carries its own gap. import assert from 'node:assert/strict'; +import { registerHooks } from 'node:module'; import { readFileSync, readdirSync, statSync } from 'node:fs'; import { dirname, join, relative } from 'node:path'; -import { fileURLToPath } from 'node:url'; +import { fileURLToPath, pathToFileURL } from 'node:url'; import { uiCopy, dateHeading } from './astro/src/packages/lingo/locale.mjs'; import { unquote, archivePrefixes, dateBadgeParts } from './astro/src/lib/chrome-copy.mjs'; const HERE = dirname(fileURLToPath(import.meta.url)); const SRC = join(HERE, 'astro/src'); +// blog.mjs imports the model layer as `@sitetile`, an Astro build alias plain node cannot resolve — +// same seam blog-date-format.test.mjs already teaches the resolver, needed here only for the +// fmtDate CJK-locale-gating behavioural check below. +registerHooks({ + resolve(spec, ctx, next) { + if (spec === '@sitetile') { + return { url: pathToFileURL(join(HERE, 'site-core.js')).href, shortCircuit: true }; + } + return next(spec, ctx); + }, +}); +const { fmtDate } = await import('./astro/src/lib/blog.mjs'); + let passed = 0; const test = (name, fn) => { try { fn(); passed++; console.log(' ✓ ' + name); } @@ -125,8 +139,14 @@ test('🔴 the whole renderer carries no other CJK UI literal', () => { // sits behind a `case` label, and the DEFAULT branch is not one of them. const blog = readFileSync(join(SRC, 'lib/blog.mjs'), 'utf8'); const fmt = blog.slice(blog.indexOf('export function fmtDate('), blog.indexOf('// footerWidgets')); - assert.match(fmt, /default:\s*\n\s*return d\.toLocaleDateString\('en-US'/, - 'the default date format must stay neutral — a CJK default is the defect, a CJK option is not'); + // Behavioural, not structural (a P3-4 fix): the default branch, and an explicit CJK format on a + // non-CJK page locale, must stay neutral — never a CJK literal — regardless of which expression + // renders it. A regex pinned to the exact source text would break on every reshuffle of this + // function that changes nothing it guards, which is exactly what P1's RangeError guard did. + assert.ok(!CJK.test(fmtDate('2024-07-13', undefined)), 'no format, no lang → must not be CJK'); + assert.ok(!CJK.test(fmtDate('2024-07-13', undefined, 'en-US')), 'no format, en-US lang → must not be CJK'); + assert.ok(!CJK.test(fmtDate('2024-07-13', 'cjk-full', 'en-US')), 'explicit CJK format on an en-US page → must not be CJK'); + assert.ok(CJK.test(fmtDate('2024-07-13', 'cjk-full', 'ja-JP')), 'explicit CJK format on a ja-JP page → must stay CJK'); for (const line of fmt.split('\n')) { if (!CJK.test(line) || /^\s*(\/\/|\*)/.test(line)) continue; assert.ok(/case '/.test(line) || /case '/.test(fmt.slice(0, fmt.indexOf(line))), @@ -196,6 +216,21 @@ test('🔴 every dateBadgeParts call site passes the language', () => { assert.deepEqual(bad, [], `call sites missing the lang argument:\n${bad.join('\n')}`); }); +test('🔴 (P3-3) every fmtDate call site passes the language', () => { + // Same shape as the dateBadgeParts guard above, for the same reason: fmtDate's third argument + // (lang) is what gates a CJK format to CJK page locales (P2-1). A call site that forgets it + // silently falls to fmtDate's own "no locale known" default — no error, a regression nobody sees + // until a report comes in — so this counts call sites structurally rather than trusting review. + const bad = []; + for (const f of walk(SRC)) { + for (const m of stripComments(readFileSync(f, 'utf8')).matchAll(/fmtDate\(([^)]*)\)/g)) { + const args = m[1].split(','); + if (args.length < 3 || !args[2].trim()) bad.push(`${relative(SRC, f)}: fmtDate(${m[1]})`); + } + } + assert.deepEqual(bad, [], `call sites missing the lang argument:\n${bad.join('\n')}`); +}); + test('🔴 the locale a SITE writes is a BCP-47 tag, not the data code', () => { // 🩸 A site declares `lang: zh-Hant`, because that is what belongs in . The table was // keyed by the data code, so every default fell through to English on a Taiwanese site — measured