You are viewing a plain text version of this content. The canonical link for it is here.
Posted to notifications@apisix.apache.org by yi...@apache.org on 2026/09/11 09:16:18 UTC

(apisix-website) branch codex/stabilize-doc-search-signals created (now 59f4b71eafb)

This is an automated email from the ASF dual-hosted git repository.

Yilialinn pushed a change to branch codex/stabilize-doc-search-signals
in repository https://gitbox.apache.org/repos/asf/apisix-website.git


      at 59f4b71eafb test(seo): lock APISIX documentation ownership signals

This branch includes the following new commits:

     new 59f4b71eafb test(seo): lock APISIX documentation ownership signals

The 1 revisions listed above as "new" are entirely new to this
repository and will be described in separate emails.  The revisions
listed as "add" were already present in the repository and have only
been added to this reference.



(apisix-website) 01/01: test(seo): lock APISIX documentation ownership signals

Posted by yi...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

Yilialinn pushed a commit to branch codex/stabilize-doc-search-signals
in repository https://gitbox.apache.org/repos/asf/apisix-website.git

commit 59f4b71eafb9ecd3b0f519931a5d056e1198c435
Author: Yilia Lin <Yi...@apache.org>
AuthorDate: Fri Sep 11 17:15:49 2026 +0800

    test(seo): lock APISIX documentation ownership signals
---
 .github/workflows/lint.yml                         |   3 +
 doc/src/theme/LayoutHead/index.tsx                 |  15 ++-
 doc/src/theme/LayoutHead/versionedDocSignals.d.mts |  12 +++
 doc/src/theme/LayoutHead/versionedDocSignals.mjs   |   9 ++
 next/package.json                                  |   2 +-
 .../check-documentation-ownership-signals.mjs      |  93 ++++++++++++++++
 next/scripts/check-historical-doc-signals.mjs      |  75 +++++++++++++
 next/scripts/check-ingress-redirects.mjs           |  32 +++++-
 next/scripts/generate-sitemaps.test.mjs            |  21 ++++
 next/tests/e2e/seo-signals.spec.mjs                |  40 ++++---
 .../fixtures/documentation-search-signals.mjs      | 119 +++++++++++++++++++++
 11 files changed, 396 insertions(+), 25 deletions(-)

diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml
index 47c3436999a..43d0ef349ca 100644
--- a/.github/workflows/lint.yml
+++ b/.github/workflows/lint.yml
@@ -42,6 +42,9 @@ jobs:
       - name: Build Astro site
         working-directory: next
         run: npm run build
+      - name: Verify documentation ownership signals
+        working-directory: next
+        run: npm run test:doc-signals
       - name: Assert Chinese learning-center retirement contract
         working-directory: next
         run: |
diff --git a/doc/src/theme/LayoutHead/index.tsx b/doc/src/theme/LayoutHead/index.tsx
index 96ca38a1ba7..cf5a42ab143 100644
--- a/doc/src/theme/LayoutHead/index.tsx
+++ b/doc/src/theme/LayoutHead/index.tsx
@@ -8,6 +8,8 @@ import OriginalLayoutHead from '@theme-original/LayoutHead';
 import { useActivePlugin } from '@theme/hooks/useDocs';
 import useDocusaurusContext from '@docusaurus/useDocusaurusContext';
 import { useLocation } from '@docusaurus/router';
+// eslint-disable-next-line import/extensions
+import { getVersionedDocSignals, versionedDocPath } from './versionedDocSignals.mjs';
 
 /**
  * Matches the version segment of versioned doc URLs, e.g.
@@ -17,8 +19,6 @@ import { useLocation } from '@docusaurus/router';
  *   /docs/apisix/next/...                    -> next
  * Keeps the same version-segment pattern as scripts/update-sitemap-loc.js.
  */
-const versionedDocPath = /^((?:\/zh)?\/docs\/[\w-]+\/)(?:(?:[\w-]+-)?\d+\.\d+(?:\.\d+)?|next)(\/.*)?$/;
-
 const normalizePath = (value: string) => value.replace(/\/$/, '');
 
 /**
@@ -51,17 +51,16 @@ const LayoutHead: FC<{ [key: string]: unknown }> = (props) => {
     && latestDoc.path.startsWith(latestRelease.path)
     ? `${activePlugin.path}${latestDoc.path.slice(latestRelease.path.length)}`
     : null;
-  const latestUrl = latestPath ? `${siteUrl}${latestPath.replace(/\/?$/, '/')}` : null;
-  const canonicalUrl = match ? latestUrl ?? `${siteUrl}${pathname}` : null;
+  const signals = getVersionedDocSignals(pathname, siteUrl, latestPath);
 
   return (
     <>
       <OriginalLayoutHead {...props} />
-      {match && (
+      {signals && (
         <Head>
-          <meta name="robots" content="noindex,follow" />
-          <meta property="og:url" content={canonicalUrl} />
-          <link rel="canonical" href={canonicalUrl} />
+          <meta name="robots" content={signals.robots} />
+          <meta property="og:url" content={signals.canonicalUrl} />
+          <link rel="canonical" href={signals.canonicalUrl} />
         </Head>
       )}
     </>
diff --git a/doc/src/theme/LayoutHead/versionedDocSignals.d.mts b/doc/src/theme/LayoutHead/versionedDocSignals.d.mts
new file mode 100644
index 00000000000..e8957f8613c
--- /dev/null
+++ b/doc/src/theme/LayoutHead/versionedDocSignals.d.mts
@@ -0,0 +1,12 @@
+export const versionedDocPath: RegExp;
+
+export interface VersionedDocSignals {
+  canonicalUrl: string;
+  robots: 'noindex,follow';
+}
+
+export function getVersionedDocSignals(
+  pathname: string,
+  siteUrl: string,
+  latestPath: string | null,
+): VersionedDocSignals | null;
diff --git a/doc/src/theme/LayoutHead/versionedDocSignals.mjs b/doc/src/theme/LayoutHead/versionedDocSignals.mjs
new file mode 100644
index 00000000000..1aa9648bb06
--- /dev/null
+++ b/doc/src/theme/LayoutHead/versionedDocSignals.mjs
@@ -0,0 +1,9 @@
+export const versionedDocPath = /^((?:\/zh)?\/docs\/[\w-]+\/)(?:(?:[\w-]+-)?\d+\.\d+(?:\.\d+)?|next)(\/.*)?$/;
+
+export function getVersionedDocSignals(pathname, siteUrl, latestPath) {
+  if (!versionedDocPath.test(pathname)) return null;
+  const canonicalUrl = latestPath
+    ? `${siteUrl}${latestPath.replace(/\/?$/, '/')}`
+    : `${siteUrl}${pathname}`;
+  return { canonicalUrl, robots: 'noindex,follow' };
+}
diff --git a/next/package.json b/next/package.json
index 1e2152ea307..a39d951f0e1 100644
--- a/next/package.json
+++ b/next/package.json
@@ -1 +1 @@
-{"name":"apisix-website-astro","type":"module","private":true,"dependencies":{"astro":"^6.0.5","entities":"^6.0.1","remark-directive":"^4.0.0","unist-util-visit":"^5.1.0"},"devDependencies":{"@playwright/test":"1.61.1","sax":"^1.6.0"},"scripts":{"sync":"node scripts/sync-content.mjs && node scripts/lint-content.mjs","build":"astro build && node scripts/generate-sitemaps.mjs","dev":"astro dev","preview":"astro preview","test:e2e":"playwright test","test:redirects":"node scripts/check-ingr [...]
+{"name":"apisix-website-astro","type":"module","private":true,"dependencies":{"astro":"^6.0.5","entities":"^6.0.1","remark-directive":"^4.0.0","unist-util-visit":"^5.1.0"},"devDependencies":{"@playwright/test":"1.61.1","sax":"^1.6.0"},"scripts":{"sync":"node scripts/sync-content.mjs && node scripts/lint-content.mjs","build":"astro build && node scripts/generate-sitemaps.mjs","dev":"astro dev","preview":"astro preview","test:e2e":"playwright test","test:doc-signals":"node scripts/check-do [...]
diff --git a/next/scripts/check-documentation-ownership-signals.mjs b/next/scripts/check-documentation-ownership-signals.mjs
new file mode 100644
index 00000000000..94c9e8f3198
--- /dev/null
+++ b/next/scripts/check-documentation-ownership-signals.mjs
@@ -0,0 +1,93 @@
+import assert from 'node:assert/strict';
+import fs from 'node:fs';
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+import {
+  APISIX_OWNED_DOCS,
+  SITE,
+  discoverApi7OwnedDocs,
+} from '../tests/fixtures/documentation-search-signals.mjs';
+
+const scriptDirectory = path.dirname(fileURLToPath(import.meta.url));
+const root = path.dirname(scriptDirectory);
+const dist = path.resolve(root, process.argv[2] ?? 'dist');
+const api7OwnedDocs = discoverApi7OwnedDocs();
+
+function attribute(tag, name) {
+  return tag.match(new RegExp(`\\b${name}=["']([^"']*)["']`, 'i'))?.[1];
+}
+
+function tags(html, tagName) {
+  return html.match(new RegExp(`<${tagName}\\b[^>]*>`, 'gi')) ?? [];
+}
+
+function pageHtml(urlPath) {
+  const file = path.join(dist, urlPath.replace(/^\//, ''), 'index.html');
+  assert.ok(fs.existsSync(file), `${urlPath} should have a built index.html`);
+  return fs.readFileSync(file, 'utf8');
+}
+
+function canonicalTags(html) {
+  return tags(html, 'link').filter((candidate) => (
+    attribute(candidate, 'rel')?.toLowerCase().split(/\s+/).includes('canonical')
+  ));
+}
+
+function robotsTags(html) {
+  return tags(html, 'meta').filter((candidate) => (
+    attribute(candidate, 'name')?.toLowerCase() === 'robots'
+  ));
+}
+
+function hasHreflang(html) {
+  return tags(html, 'link').some((tag) => (
+    attribute(tag, 'rel')?.toLowerCase().split(/\s+/).includes('alternate')
+      && attribute(tag, 'hreflang')
+  ));
+}
+
+assert.ok(api7OwnedDocs.length > 0, 'No API7-owned documents were discovered after content sync');
+assert.equal(
+  new Set(api7OwnedDocs.map(({ path: urlPath }) => urlPath)).size,
+  api7OwnedDocs.length,
+  'Discovered API7-owned document paths should be unique',
+);
+
+const sitemap = [
+  path.join(dist, 'sitemap.xml'),
+  path.join(dist, 'zh/sitemap.xml'),
+].filter(fs.existsSync).map((file) => fs.readFileSync(file, 'utf8')).join('\n');
+
+api7OwnedDocs.forEach(({ path: urlPath, canonical: expectedCanonical }) => {
+  const html = pageHtml(urlPath);
+  const pageCanonicalTags = canonicalTags(html);
+  const pageRobotsTags = robotsTags(html);
+  assert.equal(pageCanonicalTags.length, 1, `${urlPath} should have one canonical`);
+  assert.equal(attribute(pageCanonicalTags[0], 'href'), expectedCanonical, `${urlPath} canonical`);
+  assert.equal(pageRobotsTags.length, 1, `${urlPath} should have one robots directive`);
+  assert.equal(attribute(pageRobotsTags[0], 'content'), 'index,follow', `${urlPath} robots`);
+  assert.equal(hasHreflang(html), false, `${urlPath} should not emit APISIX hreflang`);
+  assert.equal(
+    sitemap.includes(`<loc>${SITE}${urlPath}</loc>`),
+    false,
+    `${urlPath} should not enter an APISIX sitemap`,
+  );
+});
+
+APISIX_OWNED_DOCS.forEach((urlPath) => {
+  const html = pageHtml(urlPath);
+  const pageCanonicalTags = canonicalTags(html);
+  const pageRobotsTags = robotsTags(html);
+  assert.equal(pageCanonicalTags.length, 1, `${urlPath} should have one canonical`);
+  assert.equal(attribute(pageCanonicalTags[0], 'href'), `${SITE}${urlPath}`, `${urlPath} canonical`);
+  assert.equal(pageRobotsTags.length, 1, `${urlPath} should have one robots directive`);
+  assert.equal(attribute(pageRobotsTags[0], 'content'), 'index,follow', `${urlPath} robots`);
+  assert.ok(
+    sitemap.includes(`<loc>${SITE}${urlPath}</loc>`),
+    `${urlPath} should enter the APISIX sitemap`,
+  );
+});
+
+console.log(
+  `Validated ${api7OwnedDocs.length} API7-owned and ${APISIX_OWNED_DOCS.length} APISIX-owned built documentation pages.`,
+);
diff --git a/next/scripts/check-historical-doc-signals.mjs b/next/scripts/check-historical-doc-signals.mjs
new file mode 100644
index 00000000000..b3a78d9d191
--- /dev/null
+++ b/next/scripts/check-historical-doc-signals.mjs
@@ -0,0 +1,75 @@
+import assert from 'node:assert/strict';
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+const scriptDirectory = path.dirname(fileURLToPath(import.meta.url));
+const signalsPath = path.resolve(
+  scriptDirectory,
+  '../../doc/src/theme/LayoutHead/versionedDocSignals.mjs',
+);
+const { getVersionedDocSignals } = await import(signalsPath);
+const site = 'https://apisix.apache.org';
+
+assert.equal(
+  getVersionedDocSignals('/docs/apisix/installation-guide/', site, null),
+  null,
+  'Current documentation should remain indexable',
+);
+assert.deepEqual(
+  getVersionedDocSignals(
+    '/docs/apisix/3.18/installation-guide/',
+    site,
+    '/docs/apisix/installation-guide/',
+  ),
+  {
+    canonicalUrl: `${site}/docs/apisix/installation-guide/`,
+    robots: 'noindex,follow',
+  },
+);
+assert.deepEqual(
+  getVersionedDocSignals(
+    '/docs/apisix/next/installation-guide/',
+    site,
+    '/docs/apisix/installation-guide/',
+  ),
+  {
+    canonicalUrl: `${site}/docs/apisix/installation-guide/`,
+    robots: 'noindex,follow',
+  },
+);
+assert.deepEqual(
+  getVersionedDocSignals('/zh/docs/apisix/3.18/removed-page/', site, null),
+  {
+    canonicalUrl: `${site}/zh/docs/apisix/3.18/removed-page/`,
+    robots: 'noindex,follow',
+  },
+);
+assert.equal(
+  getVersionedDocSignals('/docs/ingress-controller/overview/', site, null),
+  null,
+  'Current Ingress documentation should remain indexable',
+);
+assert.deepEqual(
+  getVersionedDocSignals(
+    '/docs/ingress-controller/2.0.0/overview/',
+    site,
+    '/docs/ingress-controller/overview/',
+  ),
+  {
+    canonicalUrl: `${site}/docs/ingress-controller/overview/`,
+    robots: 'noindex,follow',
+  },
+);
+assert.deepEqual(
+  getVersionedDocSignals(
+    '/docs/ingress-controller/next/overview/',
+    site,
+    '/docs/ingress-controller/overview/',
+  ),
+  {
+    canonicalUrl: `${site}/docs/ingress-controller/overview/`,
+    robots: 'noindex,follow',
+  },
+);
+
+console.log('Historical and next documentation signal policy passed.');
diff --git a/next/scripts/check-ingress-redirects.mjs b/next/scripts/check-ingress-redirects.mjs
index 1dc070a513e..8136479fe9c 100644
--- a/next/scripts/check-ingress-redirects.mjs
+++ b/next/scripts/check-ingress-redirects.mjs
@@ -5,8 +5,8 @@ import { fileURLToPath } from 'node:url';
 
 const scriptDirectory = path.dirname(fileURLToPath(import.meta.url));
 const htaccessPath = path.resolve(scriptDirectory, '../../.htaccess');
-const directives = fs
-  .readFileSync(htaccessPath, 'utf8')
+const htaccess = fs.readFileSync(htaccessPath, 'utf8');
+const directives = htaccess
   .split('\n')
   .map((line) => line.trim())
   .filter(
@@ -60,6 +60,14 @@ function firstRedirect(requestPath) {
 }
 
 const directRedirects = [
+  [
+    '/docs/apisix/',
+    '/docs/apisix/getting-started/README/',
+  ],
+  [
+    '/zh/docs/apisix/',
+    '/zh/docs/apisix/getting-started/README/',
+  ],
   [
     '/docs/ingress-controller/concepts/apisix_route/',
     '/docs/ingress-controller/reference/apisix-ingress-controller/api-reference/',
@@ -146,6 +154,24 @@ for (const [source, expectedDestination] of directRedirects) {
   );
 }
 
+directives.forEach(({ destination }) => {
+  assert.equal(
+    /^https:\/\/docs\.(?:api7\.ai|apiseven\.com)(?:\/|$)/.test(destination),
+    false,
+    `APISIX-hosted documentation copies must not redirect to ${destination}`,
+  );
+});
+
+const api7BrowserRedirect = htaccess.split('\n').find((line) => (
+  /^\s*Redirect(?:Match)?\s+3\d{2}\b/.test(line)
+    && /https:\/\/docs\.(?:api7\.ai|apiseven\.com)(?:\/|["'\s]|$)/.test(line)
+));
+assert.equal(
+  api7BrowserRedirect,
+  undefined,
+  `API7-owned documentation must remain hosted copies: ${api7BrowserRedirect}`,
+);
+
 assert.deepEqual(
   firstRedirect('/docs/ingress-controller/1.8.0/unmapped-page/'),
   {
@@ -161,4 +187,4 @@ assert.equal(
   'Redirect directives should only match complete path segments',
 );
 
-console.log(`Validated ${directRedirects.length} direct Ingress documentation redirects.`);
+console.log(`Validated ${directRedirects.length} direct documentation redirects.`);
diff --git a/next/scripts/generate-sitemaps.test.mjs b/next/scripts/generate-sitemaps.test.mjs
index 8e84b7bec68..0a74d9c0d97 100644
--- a/next/scripts/generate-sitemaps.test.mjs
+++ b/next/scripts/generate-sitemaps.test.mjs
@@ -7,12 +7,21 @@ import { spawnSync } from 'node:child_process';
 // Test-only XML parser; production sitemap generation has no parser dependency.
 // eslint-disable-next-line import/no-extraneous-dependencies
 import sax from 'sax';
+import {
+  API7_OWNED_DOC_SAMPLES,
+  APISIX_OWNED_DOCS,
+  HISTORICAL_NOINDEX_DOCS,
+} from '../tests/fixtures/documentation-search-signals.mjs';
 
 const root = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
 const generator = path.join(root, 'scripts/generate-sitemaps.mjs');
 const dist = fs.mkdtempSync(path.join(os.tmpdir(), 'apisix-sitemap-'));
 const SITE = 'https://apisix.apache.org';
 
+function pathToDirectory(urlPath) {
+  return urlPath.replace(/^\//, '').replace(/\/$/, '');
+}
+
 function writePage(url, { canonical, robots } = {}) {
   const dir = path.join(dist, url);
   fs.mkdirSync(dir, { recursive: true });
@@ -74,6 +83,13 @@ const pages = [
 try {
   pages.forEach(writePage);
   writePage('external-canonical', { canonical: 'https://docs.api7.ai/hub/cors' });
+  API7_OWNED_DOC_SAMPLES.forEach(({ path: urlPath, canonical }) => {
+    writePage(pathToDirectory(urlPath), { canonical });
+  });
+  APISIX_OWNED_DOCS.forEach((urlPath) => writePage(pathToDirectory(urlPath)));
+  HISTORICAL_NOINDEX_DOCS.forEach((urlPath) => {
+    writePage(pathToDirectory(urlPath), { robots: 'noindex,follow' });
+  });
   writePage('zh/untranslated-doc', { canonical: `${SITE}/docs/untranslated-doc/` });
   writePage('noindex-page', { robots: 'noindex,follow' });
   writePage('missing-canonical', { canonical: false });
@@ -111,6 +127,8 @@ try {
     '/search/',
     '/zh/search/',
     'external-canonical',
+    ...API7_OWNED_DOC_SAMPLES.map(({ path: urlPath }) => pathToDirectory(urlPath)),
+    ...HISTORICAL_NOINDEX_DOCS.map(pathToDirectory),
     'zh/untranslated-doc',
     'noindex-page',
     'missing-canonical',
@@ -123,6 +141,9 @@ try {
   assert.match(en, /blog\/2026\/07\/28\/release-notes/);
   assert.match(en, /docs\/general\/blog\/page\/overview/);
   assert.match(en, /docs\/apisix\/upgrade-guide-from-2\.15\.x-to-3\.0\.0/);
+  APISIX_OWNED_DOCS.forEach((urlPath) => {
+    assert.ok(en.includes(`<loc>${SITE}${urlPath}</loc>`), urlPath);
+  });
   assert.match(en, /apisix-unity-group-q&amp;a/);
   assert.match(zh, /zh\/learning-center\/<\/loc>/);
   assert.match(zh, /zh\/integrations\/redis/);
diff --git a/next/tests/e2e/seo-signals.spec.mjs b/next/tests/e2e/seo-signals.spec.mjs
index e107db1bfd8..01d59f54e1d 100644
--- a/next/tests/e2e/seo-signals.spec.mjs
+++ b/next/tests/e2e/seo-signals.spec.mjs
@@ -1,4 +1,9 @@
 import { expect, test } from '@playwright/test';
+import {
+  API7_OWNED_DOC_SAMPLES,
+  APISIX_OWNED_DOCS,
+  SITE,
+} from '../fixtures/documentation-search-signals.mjs';
 
 async function alternateMap(page) {
   return page.locator('link[rel="alternate"][hreflang]').evaluateAll((links) => Object.fromEntries(
@@ -28,20 +33,29 @@ test('untranslated Chinese fallback docs canonicalize to English without hreflan
   await expect(page.locator('link[rel="alternate"][hreflang]')).toHaveCount(0);
 });
 
-test('plugin docs retain their API7 canonical contract', async ({ page }) => {
-  await page.goto('/docs/apisix/plugins/jwt-auth/');
-  await expect(page.locator('link[rel="canonical"]')).toHaveAttribute(
-    'href',
-    'https://docs.api7.ai/hub/jwt-auth',
-  );
-  await expect(page.locator('link[rel="alternate"][hreflang]')).toHaveCount(0);
+API7_OWNED_DOC_SAMPLES.forEach(({ path, canonical }) => {
+  test(`API7-owned APISIX doc retains its cross-site canonical: ${path}`, async ({ page }) => {
+    const response = await page.goto(path);
+    expect(response?.status(), path).toBe(200);
+    expect(new URL(page.url()).pathname, path).toBe(path);
+    expect(new URL(page.url()).hostname, path).not.toBe(new URL(canonical).hostname);
+    await expect(page.locator('link[rel="canonical"]'), path).toHaveAttribute('href', canonical);
+    await expect(page.locator('meta[name="robots"]'), path).toHaveAttribute('content', 'index,follow');
+    await expect(page.locator('link[rel="alternate"][hreflang]'), path).toHaveCount(0);
+  });
+});
 
-  await page.goto('/zh/docs/apisix/plugins/jwt-auth/');
-  await expect(page.locator('link[rel="canonical"]')).toHaveAttribute(
-    'href',
-    'https://docs.apiseven.com/hub/jwt-auth',
-  );
-  await expect(page.locator('link[rel="alternate"][hreflang]')).toHaveCount(0);
+APISIX_OWNED_DOCS.forEach((path) => {
+  test(`APISIX-owned current doc remains self-canonical: ${path}`, async ({ page }) => {
+    const response = await page.goto(path);
+    expect(response?.status(), path).toBe(200);
+    expect(new URL(page.url()).pathname, path).toBe(path);
+    await expect(page.locator('link[rel="canonical"]'), path).toHaveAttribute(
+      'href',
+      `${SITE}${path}`,
+    );
+    await expect(page.locator('meta[name="robots"]'), path).toHaveAttribute('content', 'index,follow');
+  });
 });
 
 test('blog hreflang exists only for verified source pairs', async ({ page }) => {
diff --git a/next/tests/fixtures/documentation-search-signals.mjs b/next/tests/fixtures/documentation-search-signals.mjs
new file mode 100644
index 00000000000..2d8f60b535c
--- /dev/null
+++ b/next/tests/fixtures/documentation-search-signals.mjs
@@ -0,0 +1,119 @@
+import fs from 'node:fs';
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+export const SITE = 'https://apisix.apache.org';
+
+export const API7_OWNED_DOC_SAMPLES = [
+  {
+    path: '/docs/apisix/getting-started/README/',
+    canonical: 'https://docs.api7.ai/apisix/getting-started/',
+  },
+  {
+    path: '/docs/apisix/getting-started/configure-routes/',
+    canonical: 'https://docs.api7.ai/apisix/getting-started/configure-routes',
+  },
+  {
+    path: '/docs/apisix/getting-started/key-authentication/',
+    canonical: 'https://docs.api7.ai/apisix/getting-started/key-authentication',
+  },
+  {
+    path: '/docs/apisix/plugins/jwt-auth/',
+    canonical: 'https://docs.api7.ai/hub/jwt-auth',
+  },
+  {
+    path: '/docs/apisix/plugins/openid-connect/',
+    canonical: 'https://docs.api7.ai/hub/openid-connect',
+  },
+  {
+    path: '/docs/apisix/plugins/proxy-rewrite/',
+    canonical: 'https://docs.api7.ai/hub/proxy-rewrite',
+  },
+  {
+    path: '/docs/apisix/plugins/limit-count/',
+    canonical: 'https://docs.api7.ai/hub/limit-count',
+  },
+  {
+    path: '/docs/apisix/plugins/prometheus/',
+    canonical: 'https://docs.api7.ai/hub/prometheus',
+  },
+  {
+    path: '/zh/docs/apisix/getting-started/README/',
+    canonical: 'https://docs.apiseven.com/apisix/getting-started/',
+  },
+  {
+    path: '/zh/docs/apisix/plugins/jwt-auth/',
+    canonical: 'https://docs.apiseven.com/hub/jwt-auth',
+  },
+];
+
+export const APISIX_OWNED_DOCS = [
+  '/docs/',
+  '/docs/apisix/installation-guide/',
+  '/docs/ingress-controller/overview/',
+  '/docs/ingress-controller/concepts/gateway-api/',
+  '/docs/ingress-controller/concepts/deployment-architecture/',
+  '/docs/ingress-controller/reference/apisix-ingress-controller/api-reference/',
+];
+
+export const HISTORICAL_NOINDEX_DOCS = [
+  '/docs/apisix/3.18/installation-guide/',
+  '/docs/apisix/next/installation-guide/',
+  '/docs/ingress-controller/2.0.0/overview/',
+  '/docs/ingress-controller/next/overview/',
+];
+
+const fixtureDirectory = path.dirname(fileURLToPath(import.meta.url));
+const DEFAULT_CONTENT_ROOT = path.resolve(fixtureDirectory, '../../content/docs-apisix-en');
+
+function walkMarkdown(directory) {
+  if (!fs.existsSync(directory)) return [];
+  return fs.readdirSync(directory, { withFileTypes: true }).flatMap((entry) => {
+    const entryPath = path.join(directory, entry.name);
+    return entry.isDirectory()
+      ? walkMarkdown(entryPath)
+      : (entry.name.endsWith('.md') ? [entryPath] : []);
+  });
+}
+
+function readFrontmatter(file) {
+  const source = fs.readFileSync(file, 'utf8');
+  const block = source.match(/^---\r?\n([\s\S]*?)\r?\n---/);
+  if (!block) return {};
+  return Object.fromEntries(block[1].split(/\r?\n/).flatMap((line) => {
+    const match = line.match(/^(\w[\w-]*):\s*(.*)$/);
+    return match ? [[match[1], match[2].replace(/^["']|["']$/g, '').trim()]] : [];
+  }));
+}
+
+function routeId(file, contentRoot, frontmatter) {
+  const relative = path.relative(contentRoot, file).replace(/\.md$/, '').split(path.sep).join('/');
+  const directory = relative.includes('/') ? relative.slice(0, relative.lastIndexOf('/') + 1) : '';
+  if (frontmatter.slug) {
+    return frontmatter.slug.startsWith('/')
+      ? frontmatter.slug.slice(1)
+      : `${directory}${frontmatter.slug}`;
+  }
+  return frontmatter.id ? `${directory}${frontmatter.id}` : relative;
+}
+
+export function discoverApi7OwnedDocs(contentRoot = DEFAULT_CONTENT_ROOT) {
+  return walkMarkdown(contentRoot).flatMap((file) => {
+    const frontmatter = readFrontmatter(file);
+    if (!frontmatter.canonical?.startsWith('https://docs.api7.ai/')) return [];
+    const id = routeId(file, contentRoot, frontmatter);
+    return [
+      {
+        path: `/docs/apisix/${id}/`,
+        canonical: frontmatter.canonical,
+      },
+      {
+        path: `/zh/docs/apisix/${id}/`,
+        canonical: frontmatter.canonical.replace(
+          'https://docs.api7.ai',
+          'https://docs.apiseven.com',
+        ),
+      },
+    ];
+  }).sort((left, right) => left.path.localeCompare(right.path));
+}