This is an automated email from the ASF dual-hosted git repository. moonming pushed a commit to branch fix/seo-zh-canonical in repository https://gitbox.apache.org/repos/asf/apisix-website.git
commit 5c36b1c673b0cba0f722ad34741f77c120991ac0 Author: Ming Wen <[email protected]> AuthorDate: Thu Jul 30 11:14:18 2026 +0800 fix(seo): point zh docs at the Chinese hub, drop untranslated zh articles Three fixes to the Chinese tree's indexing, where the polarity was inverted: translated pages were suppressed and untranslated duplicates were promoted. Chinese plugin docs canonicalised to docs.api7.ai, which has no Chinese edition — docs.api7.ai/zh/hub/limit-count is a 404. A genuinely translated page (3,052 CJK characters) was telling search engines to index an English page instead, so a reader searching "APISIX limit-count 限流" could not find the translation that exists. They now canonicalise to docs.apiseven.com, the Chinese edition of the same hub; all six sampled targets return 200 and serve lang="zh" content. English pages are unchanged. This also resolves the one canonical that pointed at a 404: docs.api7.ai/hub/feishu-auth is dead, docs.apiseven.com/hub/feishu-auth is not. The 18 Chinese learning-center articles were never translated — 18-26 CJK characters against 1,100-2,600 English words, titles byte-identical to their English twins, self-canonical, and in the sitemap. They competed with the site's most valuable commercial pages (apisix-vs-kong, what-is-an-api-gateway) while giving Chinese readers English prose that browsers would not offer to translate, because lang="zh-CN" claims it is already Chinese. The pages are no longer built; /zh/learning-center/ lists the English articles and says so, and the indexed URLs 301 to their English equivalents. Docs body images had no loading hints and no reserved space — eight diagrams totalling 235 KB on one plugin page, all eager, all shifting the layout as they arrived. A remark plugin adds loading="lazy" and decoding="async"; the intrinsic size of a remote image is not knowable at build time, so `.prose img` reserves a 16/9 block that a declared width/height overrides. --- .htaccess | 7 +++ next/astro.config.mjs | 3 +- next/scripts/remark-image-loading.mjs | 21 +++++++++ next/src/components/CollectionPages.astro | 16 +++++-- next/src/components/StaticPages.astro | 2 +- next/src/layouts/DocPage.astro | 15 +++++- next/src/lib/content.ts | 17 +++++-- next/src/pages/learning-center/[slug].astro | 4 +- next/src/pages/learning-center/archive.astro | 2 +- next/src/pages/learning-center/index.astro | 2 +- next/src/pages/learning-center/page/[n].astro | 4 +- next/src/pages/learning-center/tags/[tag].astro | 2 +- next/src/pages/learning-center/tags/index.astro | 2 +- next/src/pages/zh/learning-center/[slug].astro | 53 ---------------------- next/src/pages/zh/learning-center/archive.astro | 5 -- next/src/pages/zh/learning-center/index.astro | 5 +- next/src/pages/zh/learning-center/page/[n].astro | 17 ------- next/src/pages/zh/learning-center/tags/[tag].astro | 24 ---------- next/src/pages/zh/learning-center/tags/index.astro | 24 ---------- next/src/styles/global.css | 8 +++- 20 files changed, 88 insertions(+), 145 deletions(-) diff --git a/.htaccess b/.htaccess index 369c473fdba..ca349b60cbe 100644 --- a/.htaccess +++ b/.htaccess @@ -164,3 +164,10 @@ RedirectMatch 301 "^(/zh)?/docs/ingress-controller/(?:next/)?getting-started/$" # Renamed docs and GitHub-repo-style paths that leak into search results RedirectMatch 301 "^(/zh)?/docs/apisix/stand-alone/?$" "$1/docs/apisix/deployment-modes/" RedirectMatch 301 "^(/zh)?/docs/apisix/en/latest/deployment-modes(\.md)?/?$" "$1/docs/apisix/deployment-modes/" + +# The Chinese learning-center articles were never translated: each served the +# English body under lang="zh-CN" with a title identical to its English twin +# and a self-referential canonical, so 18 near-duplicates competed with the +# pages they duplicated. The listing at /zh/learning-center/ now links to the +# English articles directly; these URLs are indexed, so send them there. +RedirectMatch 301 "^/zh/learning-center/(.+)$" "/learning-center/$1" diff --git a/next/astro.config.mjs b/next/astro.config.mjs index 42c85749f73..89ee08ba81c 100644 --- a/next/astro.config.mjs +++ b/next/astro.config.mjs @@ -2,6 +2,7 @@ import { defineConfig } from 'astro/config'; import remarkDirective from 'remark-directive'; import { remarkAdmonitions } from './scripts/remark-admonitions.mjs'; import { remarkHeadingIds } from './scripts/remark-heading-ids.mjs'; +import { remarkImageLoading } from './scripts/remark-image-loading.mjs'; // Static-only rebuild of apisix.apache.org. // URL contract: every public URL is identical to the current Docusaurus site @@ -14,7 +15,7 @@ export default defineConfig({ // Vite's inline threshold, so the deploy overlay's _astro/ dependency holds. build: { format: 'directory', inlineStylesheets: 'never' }, markdown: { - remarkPlugins: [remarkDirective, remarkAdmonitions, remarkHeadingIds], + remarkPlugins: [remarkDirective, remarkAdmonitions, remarkHeadingIds, remarkImageLoading], shikiConfig: { theme: 'github-dark-default' }, }, }); diff --git a/next/scripts/remark-image-loading.mjs b/next/scripts/remark-image-loading.mjs new file mode 100644 index 00000000000..343aa097f6f --- /dev/null +++ b/next/scripts/remark-image-loading.mjs @@ -0,0 +1,21 @@ +import { visit } from 'unist-util-visit'; + +// Body images in synced docs are bare `` — no dimensions, no +// loading hint — and most are remote (static.api7.ai), so the browser has +// nothing to reserve space with and fetches every one eagerly. On a page like +// /docs/apisix/plugins/openid-connect/ that is eight diagrams, ~235 KB, all +// blocking and all shifting the layout as they arrive. +// +// The intrinsic size isn't knowable at build time for remote images, so the +// space is reserved in CSS (`.prose img` carries an aspect-ratio placeholder); +// here we add the loading hints the markup can carry. +export function remarkImageLoading() { + return (tree) => { + visit(tree, 'image', (node) => { + const data = node.data || (node.data = {}); + const props = data.hProperties || (data.hProperties = {}); + if (props.loading === undefined) props.loading = 'lazy'; + if (props.decoding === undefined) props.decoding = 'async'; + }); + }; +} diff --git a/next/src/components/CollectionPages.astro b/next/src/components/CollectionPages.astro index f64d20150d2..7ad7e7031c7 100644 --- a/next/src/components/CollectionPages.astro +++ b/next/src/components/CollectionPages.astro @@ -16,9 +16,17 @@ interface Props { sub?: string; kicker?: string; withTags?: boolean; + /** Locale prefix for archive/tags/pagination links; defaults to this page's. */ + linkPrefix?: string; } -const { locale, page, posts, urlBase, heading, sub, kicker, withTags = false } = Astro.props; +const { + locale, page, posts, urlBase, heading, sub, kicker, withTags = false, linkPrefix, +} = Astro.props; const prefix = localePrefix(locale); +// Where the archive/tags/pagination siblings live. Normally alongside this +// listing, but a locale that shows another locale's articles (zh learning +// center) must point at that locale's pages, not at routes it doesn't build. +const sibling = linkPrefix ?? prefix; const pages = paginate(posts); const pagePosts = pages[page - 1] ?? []; --- @@ -29,10 +37,10 @@ const pagePosts = pages[page - 1] ?? []; kicker={kicker} locale={locale} path={page === 1 ? `${urlBase}/` : `${urlBase}/page/${page}/`} - base={`${prefix}${urlBase}/`} + base={`${sibling}${urlBase}/`} posts={pagePosts} current={page} total={pages.length} - archiveUrl={`${prefix}${urlBase}/archive/`} - tagsUrl={withTags ? `${prefix}${urlBase}/tags/` : undefined} + archiveUrl={`${sibling}${urlBase}/archive/`} + tagsUrl={withTags ? `${sibling}${urlBase}/tags/` : undefined} /> diff --git a/next/src/components/StaticPages.astro b/next/src/components/StaticPages.astro index 97451d47175..b741971d877 100644 --- a/next/src/components/StaticPages.astro +++ b/next/src/components/StaticPages.astro @@ -24,7 +24,7 @@ const DOCS_PROJECTS = [ ]; const comparisons = kind === 'comparisons' - ? getLearningPosts(locale).filter((post) => post.tags.includes('comparison')) + ? getLearningPosts().filter((post) => post.tags.includes('comparison')) : []; const aisixIndex = comparisons.findIndex((post) => post.slug === 'apisix-vs-aisix'); const kongIndex = comparisons.findIndex((post) => post.slug === 'apisix-vs-kong'); diff --git a/next/src/layouts/DocPage.astro b/next/src/layouts/DocPage.astro index aa9733694e4..ed1daaeacb2 100644 --- a/next/src/layouts/DocPage.astro +++ b/next/src/layouts/DocPage.astro @@ -39,6 +39,19 @@ const jsonLd = [{ mainEntityOfPage: `${SITE}${locale === 'zh' ? '/zh' : ''}${path}`, }]; +/** + * Upstream plugin docs carry a cross-site canonical to the English hub on + * docs.api7.ai. Emitting that unchanged on a translated Chinese page points + * search engines at a URL with no Chinese edition (docs.api7.ai/zh/hub/* is + * 404), so the translation we do have gets de-indexed in favour of a page + * that cannot rank for those queries. docs.apiseven.com is the Chinese + * edition of the same hub, so zh pages canonicalise there instead. + */ +const ZH_HUB = 'https://docs.apiseven.com'; +const rawCanonical = entry.mod.frontmatter.canonical; +const canonicalOverride = locale === 'zh' && rawCanonical?.startsWith('https://docs.api7.ai/') + ? rawCanonical.replace('https://docs.api7.ai', ZH_HUB) + : rawCanonical; --- <Base title={entry.title} @@ -46,7 +59,7 @@ const jsonLd = [{ locale={locale} path={path} jsonLd={jsonLd} - canonicalOverride={entry.mod.frontmatter.canonical} + canonicalOverride={canonicalOverride} search > <div class="container docs-layout"> diff --git a/next/src/lib/content.ts b/next/src/lib/content.ts index 14f477d0db1..2cd6adb79e4 100644 --- a/next/src/lib/content.ts +++ b/next/src/lib/content.ts @@ -188,11 +188,20 @@ export function getBlogPosts(locale: Locale): Post[] { .sort(byDateDesc); } -export function getLearningPosts(locale: Locale): Post[] { - // learning-center content is EN-only today; zh URLs serve the same entries - // (the current site does the same via Docusaurus i18n fallback). +/** + * Learning-center articles. The content is English-only, so these always + * resolve to English URLs — the Chinese listing links straight to them. + * + * We used to publish /zh/learning-center/<slug>/ as well, serving the English + * body under lang="zh-CN" with a title identical to the English page and a + * self-referential canonical. That produced 18 near-duplicate competitors to + * the site's most valuable commercial pages, and told browsers the page was + * already Chinese so they would not offer to translate it. A reader is better + * served by an honest English URL. + */ +export function getLearningPosts(): Post[] { return Object.entries(learningModules) - .map(([p, mod]) => flatPost(p, mod, '/learning-center', locale)) + .map(([p, mod]) => flatPost(p, mod, '/learning-center', 'en')) .sort(byDateDesc); } diff --git a/next/src/pages/learning-center/[slug].astro b/next/src/pages/learning-center/[slug].astro index 3109de29a29..994203f469e 100644 --- a/next/src/pages/learning-center/[slug].astro +++ b/next/src/pages/learning-center/[slug].astro @@ -3,11 +3,11 @@ import Article from '../../layouts/Article.astro'; import { getLearningPosts, relatedPosts } from '../../lib/content'; export function getStaticPaths() { - return getLearningPosts('en').map((post) => ({ params: { slug: post.slug }, props: { post } })); + return getLearningPosts().map((post) => ({ params: { slug: post.slug }, props: { post } })); } const { post } = Astro.props; const path = `/learning-center/${post.slug}/`; -const pool = getLearningPosts('en'); +const pool = getLearningPosts(); const related = relatedPosts(post, pool); const idx = pool.findIndex((p) => p.url === post.url); const newer = idx > 0 ? pool[idx - 1] : undefined; diff --git a/next/src/pages/learning-center/archive.astro b/next/src/pages/learning-center/archive.astro index b141b99dea4..c0d3080daa0 100644 --- a/next/src/pages/learning-center/archive.astro +++ b/next/src/pages/learning-center/archive.astro @@ -2,4 +2,4 @@ import BlogArchive from '../../components/BlogArchive.astro'; import { getLearningPosts } from '../../lib/content'; --- -<BlogArchive locale="en" path="/learning-center/archive/" posts={getLearningPosts('en')} heading="Learning Center Archive" kicker="Learning Center" /> +<BlogArchive locale="en" path="/learning-center/archive/" posts={getLearningPosts()} heading="Learning Center Archive" kicker="Learning Center" /> diff --git a/next/src/pages/learning-center/index.astro b/next/src/pages/learning-center/index.astro index aaf183e0e18..79498f59306 100644 --- a/next/src/pages/learning-center/index.astro +++ b/next/src/pages/learning-center/index.astro @@ -5,7 +5,7 @@ import { getLearningPosts } from '../../lib/content'; <CollectionPages locale="en" page={1} - posts={getLearningPosts('en')} + posts={getLearningPosts()} urlBase="/learning-center" heading="Learning Center" kicker="Learning Center" diff --git a/next/src/pages/learning-center/page/[n].astro b/next/src/pages/learning-center/page/[n].astro index e9b00775645..fc2a3c317f3 100644 --- a/next/src/pages/learning-center/page/[n].astro +++ b/next/src/pages/learning-center/page/[n].astro @@ -3,13 +3,13 @@ import CollectionPages from '../../../components/CollectionPages.astro'; import { getLearningPosts, paginate } from '../../../lib/content'; export function getStaticPaths() { - return paginate(getLearningPosts('en')).slice(1).map((_, i) => ({ params: { n: String(i + 2) } })); + return paginate(getLearningPosts()).slice(1).map((_, i) => ({ params: { n: String(i + 2) } })); } --- <CollectionPages locale="en" page={Number(Astro.params.n)} - posts={getLearningPosts('en')} + posts={getLearningPosts()} urlBase="/learning-center" heading="Learning Center" kicker="Learning Center" diff --git a/next/src/pages/learning-center/tags/[tag].astro b/next/src/pages/learning-center/tags/[tag].astro index b759f807f88..83f28357561 100644 --- a/next/src/pages/learning-center/tags/[tag].astro +++ b/next/src/pages/learning-center/tags/[tag].astro @@ -3,7 +3,7 @@ import ListPage from '../../../layouts/ListPage.astro'; import { getLearningPosts, groupByTag } from '../../../lib/content'; export function getStaticPaths() { - const tags = groupByTag(getLearningPosts('en')); + const tags = groupByTag(getLearningPosts()); return [...tags.entries()].map(([slug, group]) => ({ params: { tag: slug }, props: group })); } const { label, posts } = Astro.props; diff --git a/next/src/pages/learning-center/tags/index.astro b/next/src/pages/learning-center/tags/index.astro index e32ef63bb34..bd6ad8ee08d 100644 --- a/next/src/pages/learning-center/tags/index.astro +++ b/next/src/pages/learning-center/tags/index.astro @@ -2,7 +2,7 @@ import Base from '../../../layouts/Base.astro'; import { getLearningPosts, groupByTag } from '../../../lib/content'; -const tags = groupByTag(getLearningPosts('en')); +const tags = groupByTag(getLearningPosts()); const sorted = [...tags.entries()].sort((a, b) => b[1].posts.length - a[1].posts.length || a[0].localeCompare(b[0])); --- <Base title="Tags | Learning Center" locale="en" path="/learning-center/tags/"> diff --git a/next/src/pages/zh/learning-center/[slug].astro b/next/src/pages/zh/learning-center/[slug].astro deleted file mode 100644 index b8a763ac0d5..00000000000 --- a/next/src/pages/zh/learning-center/[slug].astro +++ /dev/null @@ -1,53 +0,0 @@ ---- -import Article from '../../../layouts/Article.astro'; -import { getLearningPosts, relatedPosts } from '../../../lib/content'; - -export function getStaticPaths() { - return getLearningPosts('zh').map((post) => ({ params: { slug: post.slug }, props: { post } })); -} -const { post } = Astro.props; -const path = `/learning-center/${post.slug}/`; -const pool = getLearningPosts('zh'); -const related = relatedPosts(post, pool); -const idx = pool.findIndex((p) => p.url === post.url); -const newer = idx > 0 ? pool[idx - 1] : undefined; -const older = idx >= 0 && idx < pool.length - 1 ? pool[idx + 1] : undefined; - -// learning-center pages carry structured FAQ data in frontmatter — emit FAQPage -// JSON-LD exactly like the current SEO setup. -const faq = post.mod.frontmatter.faq; -const jsonLdExtra: object[] = Array.isArray(faq) && faq.length - ? [{ - '@context': 'https://schema.org', - '@type': 'FAQPage', - mainEntity: faq.map((f: any) => ({ - '@type': 'Question', - name: f.q, - acceptedAnswer: { '@type': 'Answer', text: f.a }, - })), - }] - : []; ---- -<Article - post={post} - locale="zh" - path={path} - tagBase="/zh/learning-center" - schemaType="TechArticle" - jsonLdExtra={jsonLdExtra} - related={related} - newer={newer} - older={older} -> - {Array.isArray(faq) && faq.length > 0 && ( - <section class="faq"> - <h2>常见问题</h2> - {faq.map((f: any) => ( - <details> - <summary>{f.q}</summary> - <p>{f.a}</p> - </details> - ))} - </section> - )} -</Article> diff --git a/next/src/pages/zh/learning-center/archive.astro b/next/src/pages/zh/learning-center/archive.astro deleted file mode 100644 index b3c4f30a265..00000000000 --- a/next/src/pages/zh/learning-center/archive.astro +++ /dev/null @@ -1,5 +0,0 @@ ---- -import BlogArchive from '../../../components/BlogArchive.astro'; -import { getLearningPosts } from '../../../lib/content'; ---- -<BlogArchive locale="zh" path="/learning-center/archive/" posts={getLearningPosts('zh')} heading="学习中心归档" kicker="学习中心" /> diff --git a/next/src/pages/zh/learning-center/index.astro b/next/src/pages/zh/learning-center/index.astro index 11b7221ad5c..606f3b02175 100644 --- a/next/src/pages/zh/learning-center/index.astro +++ b/next/src/pages/zh/learning-center/index.astro @@ -5,10 +5,11 @@ import { getLearningPosts } from '../../../lib/content'; <CollectionPages locale="zh" page={1} - posts={getLearningPosts('zh')} + posts={getLearningPosts()} urlBase="/learning-center" heading="学习中心" kicker="学习中心" - sub="API 网关概念、安全、Kubernetes 与网关对比指南。" + sub="API 网关概念、安全、Kubernetes 与网关对比指南。文章内容为英文。" withTags + linkPrefix="" /> diff --git a/next/src/pages/zh/learning-center/page/[n].astro b/next/src/pages/zh/learning-center/page/[n].astro deleted file mode 100644 index bc9a09a1f12..00000000000 --- a/next/src/pages/zh/learning-center/page/[n].astro +++ /dev/null @@ -1,17 +0,0 @@ ---- -import CollectionPages from '../../../../components/CollectionPages.astro'; -import { getLearningPosts, paginate } from '../../../../lib/content'; - -export function getStaticPaths() { - return paginate(getLearningPosts('zh')).slice(1).map((_, i) => ({ params: { n: String(i + 2) } })); -} ---- -<CollectionPages - locale="zh" - page={Number(Astro.params.n)} - posts={getLearningPosts('zh')} - urlBase="/learning-center" - heading="学习中心" - kicker="学习中心" - withTags -/> diff --git a/next/src/pages/zh/learning-center/tags/[tag].astro b/next/src/pages/zh/learning-center/tags/[tag].astro deleted file mode 100644 index 53ebed3912a..00000000000 --- a/next/src/pages/zh/learning-center/tags/[tag].astro +++ /dev/null @@ -1,24 +0,0 @@ ---- -import ListPage from '../../../../layouts/ListPage.astro'; -import { getLearningPosts, groupByTag } from '../../../../lib/content'; - -export function getStaticPaths() { - const tags = groupByTag(getLearningPosts('zh')); - return [...tags.entries()].map(([slug, group]) => ({ params: { tag: slug }, props: group })); -} -const { label, posts } = Astro.props; -const tag = Astro.params.tag; ---- -<ListPage - title={`${label} | 学习中心`} - heading={label} - kicker={'标签'} - sub={`${posts.length} 篇文章`} - locale="zh" - path={`/learning-center/tags/${tag}/`} - base={`/zh/learning-center/tags/${tag}/`} - posts={posts} - current={1} - total={1} - tagsUrl={'/zh/learning-center/tags/'} -/> diff --git a/next/src/pages/zh/learning-center/tags/index.astro b/next/src/pages/zh/learning-center/tags/index.astro deleted file mode 100644 index 34eee02132f..00000000000 --- a/next/src/pages/zh/learning-center/tags/index.astro +++ /dev/null @@ -1,24 +0,0 @@ ---- -import Base from '../../../../layouts/Base.astro'; -import { getLearningPosts, groupByTag } from '../../../../lib/content'; - -const tags = groupByTag(getLearningPosts('zh')); -const sorted = [...tags.entries()].sort((a, b) => b[1].posts.length - a[1].posts.length || a[0].localeCompare(b[0])); ---- -<Base title="标签 | 学习中心" locale="zh" path="/learning-center/tags/"> - <header class="page-head"> - <div class="container"> - <div class="page-title"> - <p class="kicker">学习中心</p> - <h1>学习中心标签</h1> - </div> - </div> - </header> - <div class="container"> - <nav class="tag-row tag-cloud" aria-label="标签"> - {sorted.map(([slug, { label, posts }]) => ( - <a class="tag" href={`/zh/learning-center/tags/${slug}/`}>{label} <span class="count">{posts.length}</span></a> - ))} - </nav> - </div> -</Base> diff --git a/next/src/styles/global.css b/next/src/styles/global.css index ab89ffd5a30..cc4362736f8 100644 --- a/next/src/styles/global.css +++ b/next/src/styles/global.css @@ -276,7 +276,13 @@ a.tag:focus-visible { outline: 2px solid var(--color-primary); outline-offset: 2 .prose a:hover { color: var(--color-primary); text-decoration-color: currentcolor; } .prose :not(pre) > code { overflow-wrap: anywhere; } .prose iframe { width: 100%; aspect-ratio: 16 / 9; height: auto; border: 0; border-radius: 8px; } -.prose img { border-radius: 8px; } +/* Synced docs images are remote and carry no intrinsic size, so reserve a + plausible block before they load — without this the whole article reflows as + each diagram arrives. `height: auto` still wins once the real ratio is known, + so correctly-proportioned images are unaffected. */ +.prose img { border-radius: 8px; aspect-ratio: 16 / 9; height: auto; object-fit: contain; } +/* Images that declare their own dimensions know better. */ +.prose img[width][height] { aspect-ratio: auto; } .prose code { font-family: var(--font-mono); font-size: .86em; background: var(--color-code-bg); padding: .12em .35em; border-radius: 4px; } .prose pre { background: #0d1117; color: #e6edf3; border-radius: 10px; padding: 1rem 1.2rem; overflow-x: auto; font-size: .86rem; line-height: 1.6; } .prose .code-title { background: #21262d; color: #e6edf3; font-family: var(--font-mono); font-size: .78rem; padding: .45rem 1.2rem; border-radius: 10px 10px 0 0; margin-bottom: 0; }
