SEO: real robots.txt + hreflang pairs + BlogPosting schema
Deploy / build (push) Successful in 18s

robots.txt was absent at the origin, so Cloudflare served its Free-plan
Content Signals Policy placeholder — a file with no User-agent, no Sitemap
and no directives. Adds src/pages/robots.txt.ts (origin 200 → CF merges
instead of substituting) pointing at sitemap-index.xml, disallowing
/pagefind/, and expressing search=yes / ai-input=yes / ai-train=no in line
with the two-lane SEO+GEO strategy in docs/seo-reference.md. Citation
crawlers (Google-Extended, OAI-SearchBot, PerplexityBot, ClaudeBot) stay
allowed; only training-only harvesters blocked.

hreflang was never emitted despite full EN/ZH i18n: both language versions
sat in the sitemap with no cross-reference. BaseLayout gains altLocaleUrl
and emits en/zh/x-default per page; every post pair, category pair, and
landing page now cross-links.

Also adds BlogPosting JSON-LD (@id-linked to the site-wide Person node)
so Google has a rich-result-eligible node per article, and creates the
missing /zh/about/ page — the EN about page pointed its language switch at
a 404, and /about/ was the only unpaired page after hreflang landed.
This commit is contained in:
2026-09-19 21:41:22 +08:00
parent 86f3a977b9
commit 7e0802f913
12 changed files with 163 additions and 3 deletions
+21
View File
@@ -15,6 +15,9 @@ interface Props {
// When provided, the language switch links to it; otherwise it falls back to
// the section landing page (/ for EN, /zh/ for ZH).
switchHref?: string;
// Absolute URL of the other-language version of THIS page (built by each
// page) — drives hreflang so EN/ZH twins don't compete for the same query.
altLocaleUrl?: string;
article?: {
publishedTime: string;
modifiedTime?: string;
@@ -30,12 +33,23 @@ const {
ogImage = '/og-default.png',
type = 'website',
switchHref,
altLocaleUrl,
article,
} = Astro.props;
const canonical = new URL(Astro.url.pathname, SITE.url).href;
const ogImageUrl = new URL(ogImage, SITE.url).href;
// hreflang: only emitted when the page has a known counterpart in the other
// language. x-default points at the English version (English-first site).
const hreflang = altLocaleUrl
? [
{ hreflang: 'en', href: lang === 'en' ? canonical : altLocaleUrl },
{ hreflang: 'zh', href: lang === 'zh' ? canonical : altLocaleUrl },
{ hreflang: 'x-default', href: lang === 'en' ? canonical : altLocaleUrl },
]
: [];
// Language switcher: opposite of current page lang.
// If a per-page translation link is provided, use it; else fall back to the section landing.
const isZh = lang === 'zh';
@@ -59,6 +73,11 @@ const nav = isZh
<meta name="description" content={description} />
<link rel="canonical" href={canonical} />
<!-- hreflang: this page + its other-language twin -->
{hreflang.map((alt) => (
<link rel="alternate" hreflang={alt.hreflang} href={alt.href} />
))}
<!-- Open Graph -->
<meta property="og:type" content={type} />
<meta property="og:site_name" content={SITE.name} />
@@ -69,6 +88,7 @@ const nav = isZh
<meta property="og:image:width" content="1200" />
<meta property="og:image:height" content="630" />
<meta property="og:locale" content={SITE.locale} />
{lang === 'zh' && <meta property="og:locale:alternate" content="en_US" />}
<!-- Twitter -->
<meta name="twitter:card" content="summary_large_image" />
@@ -101,6 +121,7 @@ const nav = isZh
<script type="application/ld+json" set:html={JSON.stringify({
'@context': 'https://schema.org',
'@type': 'Person',
'@id': `${SITE.url}/#person`,
name: SITE.author,
alternateName: SITE.handle,
url: SITE.url,