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:
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -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 个容器的 homelab(Docker、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>
|
||||
@@ -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>}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user