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&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));
+}