Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/sitetile/astro/src/components/ArchiveView.astro
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ const archive = showSidebar ? groupByArchive(corpus) : [];
<span class="bl-date-year">{dateBadgeParts(p.date, meta.lang).year}</span>
</time>
) : (
<time class="bl-date">{fmtDate(p.date, dateFormat)}</time>
<time class="bl-date">{fmtDate(p.date, dateFormat, meta.lang)}</time>
))}
<h2 class="bl-title">{p.title}</h2>
{(p.description || p.excerpt) && <p class="bl-desc">{p.description || p.excerpt}</p>}
Expand Down
2 changes: 1 addition & 1 deletion packages/sitetile/astro/src/components/BlogIndexView.astro
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@ const title = isHome
<span class="bl-date-year">{dateBadgeParts(p.date, meta.lang).year}</span>
</time>
) : (
<time class="bl-date">{fmtDate(p.date, dateFormat)}</time>
<time class="bl-date">{fmtDate(p.date, dateFormat, meta.lang)}</time>
))}
<h2 class="bl-title">{p.title}</h2>
{(p.description || p.excerpt) && <p class="bl-desc">{p.description || p.excerpt}</p>}
Expand Down
14 changes: 8 additions & 6 deletions packages/sitetile/astro/src/components/PostArticle.astro
Original file line number Diff line number Diff line change
Expand Up @@ -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 → <br>), for
// WP-sourced recasts whose live keeps manual line breaks. Default (unset) = CommonMark join.
Expand Down Expand Up @@ -93,7 +95,7 @@ const crumbBlog = String(meta['blog-title'] || 'Blog').trim();
<a class="bl-back" href={blogBase(meta)}><Arrow direction="left" size={14} />All posts</a>
)}
<header class="bl-post-head">
{post.date && <time class="bl-date">{fmtDate(post.date, dateFormat)}</time>}
{post.date && <time class="bl-date">{fmtDate(post.date, dateFormat, meta.lang)}</time>}
<h1>{post.title}</h1>
{author && <p class="bl-byline">{bylineLabel} {author}</p>}
{/* 🩸 added 2026-07-13: top-of-post share pair (see topShareLinks above) — matches live
Expand Down Expand Up @@ -122,14 +124,14 @@ const crumbBlog = String(meta['blog-title'] || 'Blog').trim();
{newer ? (
<a class="bl-post-nav-item bl-post-nav-newer" href={postUrl(newer, meta)}>
<span class="bl-post-nav-label"><Arrow direction="left" size={12} />{newerLabel}</span>
{newer.date && <span class="bl-post-nav-date">{fmtDate(newer.date, dateFormat)}</span>}
{newer.date && <span class="bl-post-nav-date">{fmtDate(newer.date, dateFormat, meta.lang)}</span>}
<span class="bl-post-nav-title">{newer.title}</span>
</a>
) : <span />}
{older && (
<a class="bl-post-nav-item bl-post-nav-older" href={postUrl(older, meta)}>
<span class="bl-post-nav-label">{olderLabel}<Arrow direction="right" size={12} /></span>
{older.date && <span class="bl-post-nav-date">{fmtDate(older.date, dateFormat)}</span>}
{older.date && <span class="bl-post-nav-date">{fmtDate(older.date, dateFormat, meta.lang)}</span>}
<span class="bl-post-nav-title">{older.title}</span>
</a>
)}
Expand All @@ -143,7 +145,7 @@ const crumbBlog = String(meta['blog-title'] || 'Blog').trim();
<li>
<a href={p.href}>
{p.image && <img class="bl-keep-img" src={p.image} alt="" loading="lazy" />}
{p.date && <time datetime={p.date}>{fmtDate(p.date, dateFormat)}</time>}
{p.date && <time datetime={p.date}>{fmtDate(p.date, dateFormat, meta.lang)}</time>}
<h3>{p.title}</h3>
{p.description && <p>{p.description}</p>}
</a>
Expand All @@ -160,7 +162,7 @@ const crumbBlog = String(meta['blog-title'] || 'Blog').trim();
<li>
<a href={p.href}>
{p.image && <img class="bl-keep-img" src={p.image} alt="" loading="lazy" />}
{p.date && <time datetime={p.date}>{fmtDate(p.date, dateFormat)}</time>}
{p.date && <time datetime={p.date}>{fmtDate(p.date, dateFormat, meta.lang)}</time>}
<h3>{p.title}</h3>
{p.description && <p>{p.description}</p>}
</a>
Expand Down
86 changes: 76 additions & 10 deletions packages/sitetile/astro/src/lib/blog.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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|$)/;
Expand Down Expand Up @@ -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 日
Expand All @@ -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);
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,7 @@ const pageHref = (n) => (n === 1 ? `/search/label/${tag}` : `/search/label/${tag
<span class="bl-date-year">{dateBadgeParts(p.date, meta.lang).year}</span>
</time>
) : (
<time class="bl-date">{fmtDate(p.date, dateFormat)}</time>
<time class="bl-date">{fmtDate(p.date, dateFormat, meta.lang)}</time>
))}
<h2 class="bl-title">{p.title}</h2>
{(p.description || p.excerpt) && <p class="bl-desc">{p.description || p.excerpt}</p>}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ const pageHref = (n) => (n === 1 ? `/search/label/${tag}` : `/search/label/${tag
<span class="bl-date-year">{dateBadgeParts(p.date, meta.lang).year}</span>
</time>
) : (
<time class="bl-date">{fmtDate(p.date, dateFormat)}</time>
<time class="bl-date">{fmtDate(p.date, dateFormat, meta.lang)}</time>
))}
<h2 class="bl-title">{p.title}</h2>
{(p.description || p.excerpt) && <p class="bl-desc">{p.description || p.excerpt}</p>}
Expand Down
Loading
Loading