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:
1 parent
86f3a977b9
commit
7e0802f913
12 files changed
+163
-3
No files matched your search
@@ -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