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

Yilialinn pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/apisix-website.git


The following commit(s) were added to refs/heads/master by this push:
     new 5e7af4270cc test(seo): lock APISIX documentation ownership signals 
(#2119)
5e7af4270cc is described below

commit 5e7af4270cc58f8d0c3ec0c06d89b1de125e0463
Author: Yilia Lin <[email protected]>
AuthorDate: Mon Sep 14 14:06:08 2026 +0800

    test(seo): lock APISIX documentation ownership signals (#2119)
---
 .github/workflows/lint.yml                         |  23 ++++
 doc/src/theme/LayoutHead/index.tsx                 |  15 ++-
 doc/src/theme/LayoutHead/versionedDocSignals.d.mts |  12 +++
 doc/src/theme/LayoutHead/versionedDocSignals.mjs   |   9 ++
 next/package-lock.json                             |   3 +-
 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 +++++++++++++++++++++
 12 files changed, 418 insertions(+), 26 deletions(-)

diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml
index 47c3436999a..94eb9ce0052 100644
--- a/.github/workflows/lint.yml
+++ b/.github/workflows/lint.yml
@@ -34,6 +34,26 @@ jobs:
             https://github.com/apache/apisix.git \
             .sync/apisix
           (cd .sync/apisix && git sparse-checkout set docs)
+          INGRESS_VERSION=$(
+            git ls-remote --refs \
+              https://github.com/apache/apisix-ingress-controller.git \
+              'refs/heads/v*' 'refs/tags/v*' \
+              | sed 's|.*refs/[a-z]*/v||' \
+              | node -e "
+                  const semver = require('semver');
+                  const versions = require('fs').readFileSync(0, 
'utf8').split('\\n')
+                    .map((version) => version.trim())
+                    .filter((version) => semver.valid(semver.coerce(version)));
+                  versions.sort((left, right) => 
semver.compare(semver.coerce(left), semver.coerce(right)));
+                  if (versions.length) console.log(versions[versions.length - 
1]);
+                "
+          )
+          test -n "$INGRESS_VERSION"
+          git clone --depth 1 --filter=blob:none --sparse -q \
+            -b "v$INGRESS_VERSION" \
+            https://github.com/apache/apisix-ingress-controller.git \
+            .sync/apisix-ingress-controller
+          (cd .sync/apisix-ingress-controller && git sparse-checkout set docs)
       - name: Sync site content
         working-directory: next
         run: node scripts/sync-content.mjs
@@ -42,6 +62,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-lock.json b/next/package-lock.json
index c31fece5798..227ded4842d 100644
--- a/next/package-lock.json
+++ b/next/package-lock.json
@@ -13,7 +13,8 @@
       },
       "devDependencies": {
         "@playwright/test": "1.61.1",
-        "sax": "^1.6.0"
+        "sax": "^1.6.0",
+        "semver": "^7.8.5"
       }
     },
     "node_modules/@astrojs/compiler": {
diff --git a/next/package.json b/next/package.json
index 1e2152ea307..e6fedc2705b 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","semver":"^7.8.5"},"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":"nod [...]
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));
+}

Reply via email to