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,
+6 -1
View File
@@ -4,7 +4,12 @@ import AuthorCard from '../components/AuthorCard.astro';
import { SITE } from '../config';
---
<BaseLayout title="About — Mr Hoelee" description={`About ${SITE.author} (${SITE.handle}).`}>
<BaseLayout
title="About — Mr Hoelee"
description={`About ${SITE.author} (${SITE.handle}).`}
switchHref="/zh/about/"
altLocaleUrl={`${SITE.url}/zh/about/`}
>
<h1>About</h1>
<p>
I'm {SITE.author} ({SITE.handle}), a full-stack developer and DevOps engineer
+2
View File
@@ -1,5 +1,6 @@
---
import BaseLayout from '../../layouts/BaseLayout.astro';
import { SITE } from '../../config';
import PostList from '../../components/PostList.astro';
import CategoryArt from '../../components/CategoryArt.astro';
import { getCollection } from 'astro:content';
@@ -28,6 +29,7 @@ const blurb = meta?.blurb.en;
title={`${name} — Mr Hoelee`}
description={blurb ?? `Posts in the ${category} category.`}
switchHref={`/zh/categories/${category}/`}
altLocaleUrl={`${SITE.url}/zh/categories/${category}/`}
>
<h1>{name}</h1>
{blurb && <p class="lede">{blurb}</p>}
+2
View File
@@ -3,6 +3,7 @@ import BaseLayout from '../../layouts/BaseLayout.astro';
import CategoryGrid from '../../components/CategoryGrid.astro';
import { getCollection } from 'astro:content';
import { isEn } from '../../lib/lang';
import { SITE } from '../../config';
const posts = await getCollection('posts', isEn);
@@ -17,6 +18,7 @@ for (const p of posts) {
title="Categories — Mr Hoelee"
description={`Browse ${posts.length} technical posts by category: case studies, DevOps, AI, engineering, tutorials and quick notes.`}
switchHref="/zh/categories/"
altLocaleUrl={`${SITE.url}/zh/categories/`}
>
<div class="page-head">
<h1>Categories</h1>
+1 -1
View File
@@ -9,7 +9,7 @@ import { sortByUpdated } from '../lib/sort';
const posts = sortByUpdated(await getCollection('posts', isEn));
---
<BaseLayout title={SITE.title} description={SITE.description}>
<BaseLayout title={SITE.title} description={SITE.description} altLocaleUrl={`${SITE.url}/zh/`}>
<section>
<h1>Hi, I'm {SITE.author}.</h1>
<p>
+25
View File
@@ -3,6 +3,7 @@ import BaseLayout from '../../layouts/BaseLayout.astro';
import AuthorCard from '../../components/AuthorCard.astro';
import { getCollection, render } from 'astro:content';
import { postLang } from '../../lib/lang';
import { SITE } from '../../config';
export async function getStaticPaths() {
const posts = await getCollection('posts', ({ data }) => !data.draft);
@@ -45,6 +46,28 @@ const otherLangSlug = lang === 'en' ? `zh/${post.slug}` : post.slug.replace(/^zh
const translation = post.data.translation
?? (all.some((p) => p.slug === otherLangSlug) ? otherLangSlug : undefined);
const switchHref = translation ? `/posts/${translation}/` : undefined;
const altLocaleUrl = translation ? new URL(`/posts/${translation}/`, SITE.url).href : undefined;
// BlogPosting structured data — gives Google a rich-result-eligible node and
// links the article to the site-wide Person node (E-E-A-T).
const postUrl = new URL(`/posts/${post.slug}/`, SITE.url).href;
const jsonLd = {
'@context': 'https://schema.org',
'@type': 'BlogPosting',
mainEntityOfPage: { '@type': 'WebPage', '@id': postUrl },
headline: post.data.title,
description: post.data.description,
image: new URL(post.data.ogImage ?? '/og-default.png', SITE.url).href,
inLanguage: lang === 'zh' ? 'zh' : 'en',
datePublished: post.data.pubDate.toISOString(),
dateModified: (post.data.updatedDate ?? post.data.pubDate).toISOString(),
author: { '@id': `${SITE.url}/#person` },
publisher: { '@id': `${SITE.url}/#person` },
isPartOf: { '@type': 'Blog', '@id': `${SITE.url}/#blog`, name: SITE.name, url: SITE.url },
articleSection: post.data.category,
keywords: post.data.tags.join(', '),
wordCount: words,
};
---
<BaseLayout
@@ -54,6 +77,7 @@ const switchHref = translation ? `/posts/${translation}/` : undefined;
ogImage={post.data.ogImage ?? '/og-default.png'}
type="article"
switchHref={switchHref}
altLocaleUrl={altLocaleUrl}
article={{
publishedTime: post.data.pubDate.toISOString(),
modifiedTime: post.data.updatedDate?.toISOString(),
@@ -61,6 +85,7 @@ const switchHref = translation ? `/posts/${translation}/` : undefined;
category: post.data.category,
}}
>
<script type="application/ld+json" set:html={JSON.stringify(jsonLd)} />
<article>
<header class="article-head">
<h1>{post.data.title}</h1>
+7 -1
View File
@@ -4,11 +4,17 @@ import PostList from '../../components/PostList.astro';
import { getCollection } from 'astro:content';
import { isEn } from '../../lib/lang';
import { sortByUpdated } from '../../lib/sort';
import { SITE } from '../../config';
const posts = sortByUpdated(await getCollection('posts', isEn));
---
<BaseLayout title="Posts — Mr Hoelee" description="All posts on Mr Hoelee's blog.">
<BaseLayout
title="Posts — Mr Hoelee"
description="All posts on Mr Hoelee's blog."
switchHref="/zh/"
altLocaleUrl={`${SITE.url}/zh/`}
>
<h1>Posts</h1>
{posts.length === 0 ? (
<p><em>No posts yet.</em></p>
+54
View File
@@ -0,0 +1,54 @@
import type { APIRoute } from 'astro';
import { SITE } from '../config';
/**
* robots.txt — served from the origin so Cloudflare merges its managed
* block instead of substituting the placeholder Content Signals Policy.
*
* Policy here: allow classic search (this blog is a name-search / GEO play)
* and allow the citation crawlers that power AI Overviews / ChatGPT Search /
* Perplexity, because being cited is the point of the two-lane strategy in
* docs/seo-reference.md. Block only the training-only harvesters that give
* no referral traffic back.
*/
export const GET: APIRoute = () => {
const body = `# robots.txt — blog.hoelee.com
# Policy: index freely. Allow citation/answer engines, block training-only harvesters.
User-agent: *
Allow: /
# Pagefind's search shards are build artifacts, not content.
Disallow: /pagefind/
# AI trainers that send no traffic back (blocked).
User-agent: CCBot
Disallow: /
User-agent: Bytespider
Disallow: /
User-agent: Amazonbot
Disallow: /
User-agent: Applebot-Extended
Disallow: /
# Deliberately ALLOWED (cite us, don't train on us — enforced by Cloudflare
# Content-Signal below): Googlebot / Google-Extended (AI Overviews, Gemini),
# OAI-SearchBot / GPTBot (ChatGPT Search), PerplexityBot, ClaudeBot, Bingbot.
# Content signals: machine-readable preference statements (contentsignals.org).
User-Agent: *
Content-Signal: search=yes, ai-input=yes, ai-train=no
Sitemap: ${SITE.url}/sitemap-index.xml
`;
return new Response(body, {
headers: {
'Content-Type': 'text/plain; charset=utf-8',
'Cache-Control': 'public, max-age=3600',
},
});
};
+40
View File
@@ -0,0 +1,40 @@
---
import BaseLayout from '../../layouts/BaseLayout.astro';
import AuthorCard from '../../components/AuthorCard.astro';
import { SITE } from '../../config';
---
<BaseLayout
title="关于 — Mr Hoelee"
description={`关于 ${SITE.author}${SITE.handle})。`}
lang="zh"
switchHref="/about/"
altLocaleUrl={`${SITE.url}/about/`}
>
<h1>关于</h1>
<p>
我是 {SITE.author}{SITE.handle}),一名base在马来西亚的全栈开发者兼 DevOps
工程师。我做 Web 应用——PHP/CodeIgniter、Java/Spring、JavaScript/TypeScript/React——
并自托管一个约 140 个容器的 homelabDocker、Traefik、nginx、Cloudflare tunnel)。
我运营 Hoelee Enterprise,一家面向马来西亚中小企业的网站设计开发公司
(邮箱托管只是副业,并非主业)。
</p>
<p>
这个博客就是我的动态作品集:我把自己构建的东西记录下来,从自托管 CI/CD、
Docker 编排,到 Web3 实验与 AI 自动化。无论你是招聘方、客户,还是同路的
开发者,这里的文章都是我能做什么最诚实的呈现。
</p>
<AuthorCard bio="base在马来西亚的全栈开发者兼 DevOps 工程师。接受远程/混合办公机会。" />
<h2>联系方式</h2>
<p>
<a href={`mailto:${SITE.email}`}>{SITE.email}</a>
<span class="sep">·</span>
<a href={SITE.linkedin} target="_blank" rel="noopener">LinkedIn</a>
<span class="sep">·</span>
<a href={SITE.gitea} target="_blank" rel="noopener">git.hoelee.com</a>
<span class="sep">·</span>
<a href={SITE.website} target="_blank" rel="noopener">www.hoelee.com</a>
</p>
</BaseLayout>
+2
View File
@@ -1,5 +1,6 @@
---
import BaseLayout from '../../../layouts/BaseLayout.astro';
import { SITE } from '../../../config';
import PostList from '../../../components/PostList.astro';
import CategoryArt from '../../../components/CategoryArt.astro';
import { getCollection } from 'astro:content';
@@ -29,6 +30,7 @@ const blurb = meta?.blurb.zh;
description={blurb ?? `「${category}」分类下的文章。`}
lang="zh"
switchHref={`/categories/${category}/`}
altLocaleUrl={`${SITE.url}/categories/${category}/`}
>
<h1>{name}</h1>
{blurb && <p class="lede">{blurb}</p>}
+2
View File
@@ -3,6 +3,7 @@ import BaseLayout from '../../../layouts/BaseLayout.astro';
import CategoryGrid from '../../../components/CategoryGrid.astro';
import { getCollection } from 'astro:content';
import { isZh } from '../../../lib/lang';
import { SITE } from '../../../config';
const posts = await getCollection('posts', isZh);
@@ -18,6 +19,7 @@ for (const p of posts) {
description={`按分类浏览 ${posts.length} 篇中文技术文章:案例研究、DevOps、AI、工程开发、教程与随记。`}
lang="zh"
switchHref="/categories/"
altLocaleUrl={`${SITE.url}/categories/`}
>
<div class="page-head">
<h1>分类</h1>
+1
View File
@@ -13,6 +13,7 @@ const posts = sortByUpdated(await getCollection('posts', isZh));
title={`${SITE.handle} — 技术博客`}
description="Hoelee 的技术博客中文版:工程、DevOps、自托管、AI 自动化与 Web3 实验。"
lang="zh"
altLocaleUrl={`${SITE.url}/`}
>
<section>
<h1>你好,我是 {SITE.author}。</h1>