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 <[email protected]> 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&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)); +}
