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

kayx23 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 8dd5b623c4d fix: count every page view on Astro and Docusaurus pages 
(#2152)
8dd5b623c4d is described below

commit 8dd5b623c4dbeb04c34b9743836d260120c2223a
Author: Zhi Yuan Ju <[email protected]>
AuthorDate: Fri Nov 13 09:31:37 2026 +0800

    fix: count every page view on Astro and Docusaurus pages (#2152)
    
    * fix: restore Matomo tracking on Astro pages
    
    * fix: count every page view on Astro and Docusaurus pages
    
    Share one Matomo snippet between the Docusaurus template and the Astro
    layout so site ID, hostname gating and landing-view pinning cannot
    drift. Track Docusaurus client-side route changes, which previously
    never sent a page view. Assert in CI that every published page carries
    exactly one tracker.
    
    Generated with [Devin](https://devin.ai)
    
    Co-Authored-By: Devin 
<158243242+devin-ai-integration[bot]@users.noreply.github.com>
    
    ---------
    
    Co-authored-by: Devin 
<158243242+devin-ai-integration[bot]@users.noreply.github.com>
---
 .github/workflows/deploy.yml            |  22 ++++++
 blog/en/docusaurus.config.js            |   3 +-
 blog/zh/docusaurus.config.js            |   3 +-
 config/ssrTemplate.js                   |  32 ++-------
 doc/docusaurus.config.js                |   3 +-
 next/MIGRATION.md                       |   4 +-
 next/README.md                          |   4 +-
 next/src/layouts/Base.astro             |   4 +-
 next/tests/e2e/analytics.spec.mjs       | 114 ++++++++++++++++++++++++++++++++
 next/tests/e2e/ecosystem-pages.spec.mjs |   2 +-
 shared/matomo-route-tracking.js         |  19 ++++++
 shared/matomo-tracker.js                |  30 +++++++++
 website/docusaurus.config.js            |   3 +-
 13 files changed, 208 insertions(+), 35 deletions(-)

diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml
index b7e4c66689a..6fc9ec6789a 100644
--- a/.github/workflows/deploy.yml
+++ b/.github/workflows/deploy.yml
@@ -511,6 +511,28 @@ jobs:
           test -f website/build/img/integrations/icon-prometheus.svg
           test -f website/build/img/architecture.svg
 
+      - name: Assert every published page carries one Matomo tracker
+        run: |
+          node -e '
+            const fs = require("fs"), path = require("path");
+            const walk = (dir, out = []) => {
+              for (const entry of fs.readdirSync(dir, { withFileTypes: true 
})) {
+                const full = path.join(dir, entry.name);
+                if (entry.isDirectory()) walk(full, out);
+                else if (entry.name.endsWith(".html")) out.push(full);
+              }
+              return out;
+            };
+            const pages = walk("website/build");
+            const bad = pages.filter((file) => (fs.readFileSync(file, 
"utf8").match(/data-analytics="matomo"/g) || []).length !== 1);
+            if (bad.length) {
+              console.error(`${bad.length} of ${pages.length} pages lack 
exactly one Matomo tracker:`);
+              console.error(bad.slice(0, 50).join("\n"));
+              process.exit(1);
+            }
+            console.log(`${pages.length} pages carry exactly one Matomo 
tracker`);
+          '
+
       - name: Assert every version-picker link resolves
         run: |
           # The picker used to link version roots (/docs/apisix/3.15/), which
diff --git a/blog/en/docusaurus.config.js b/blog/en/docusaurus.config.js
index c4001e92dd9..1cf869069e1 100644
--- a/blog/en/docusaurus.config.js
+++ b/blog/en/docusaurus.config.js
@@ -1,4 +1,4 @@
-const { ssrTemplate } = require('../../config/ssrTemplate');
+const { ssrTemplate, matomoRouteTracking } = 
require('../../config/ssrTemplate');
 
 const description = 'APISIX is a dynamic, high-performance API Gateway with 
features like load balancing, canary release, authentication, and 
observability. As an AI Gateway, it enables AI proxying, LLM load balancing, 
retries, fallbacks, token-based rate limiting, and security to enhance AI agent 
efficiency and reliability.';
 
@@ -131,6 +131,7 @@ module.exports = {
     },
   ],
   stylesheets: [],
+  clientModules: [matomoRouteTracking],
   /**
    * We need to check if we build site for preview env,
    * or preview site will load static assets from the asf-site branch.
diff --git a/blog/zh/docusaurus.config.js b/blog/zh/docusaurus.config.js
index 7dcf22c4fb6..3e8d530628b 100644
--- a/blog/zh/docusaurus.config.js
+++ b/blog/zh/docusaurus.config.js
@@ -1,5 +1,5 @@
 const readingTime = require('reading-time');
-const { ssrTemplate } = require('../../config/ssrTemplate');
+const { ssrTemplate, matomoRouteTracking } = 
require('../../config/ssrTemplate');
 
 const description = 'APISIX is a dynamic, high-performance API Gateway with 
features like load balancing, canary release, authentication, and 
observability. As an AI Gateway, it enables AI proxying, LLM load balancing, 
retries, fallbacks, token-based rate limiting, and security to enhance AI agent 
efficiency and reliability.';
 
@@ -133,6 +133,7 @@ module.exports = {
     },
   ],
   stylesheets: [],
+  clientModules: [matomoRouteTracking],
   /**
    * We need to check if we build site for preview env,
    * or preview site will load static assets from the asf-site branch.
diff --git a/config/ssrTemplate.js b/config/ssrTemplate.js
index 8bd22cd7d32..efa5c867ca0 100644
--- a/config/ssrTemplate.js
+++ b/config/ssrTemplate.js
@@ -1,3 +1,8 @@
+const fs = require('fs');
+const path = require('path');
+
+const matomoTracker = fs.readFileSync(path.join(__dirname, 
'../shared/matomo-tracker.js'), 'utf8');
+
 module.exports = {
   ssrTemplate: `<!DOCTYPE html>
   <html <%~ it.htmlAttributes %>>
@@ -26,31 +31,7 @@ module.exports = {
         <link rel="stylesheet" href="<%= process.env.preview ? '' : 
'https://apisix-website-static.apiseven.com' %><%= it.baseUrl %><%= stylesheet 
%>" />
       <% }); %>
       <!-- Matomo from the ASF -->
-      <script>
-        window._paq = window._paq || [];
-        function loadMatomo() {
-          var _paq = window._paq;
-          /* tracker methods like "setCustomDimension" should be called before
-      "trackPageView" */
-          /* We explicitly disable cookie tracking to avoid privacy issues */
-          _paq.push(['disableCookies']);
-          _paq.push(['trackPageView']);
-          _paq.push(['enableLinkTracking']);
-          (function() {
-            var u="https://analytics.apache.org/";;
-            _paq.push(['setTrackerUrl', u+'matomo.php']);
-            _paq.push(['setSiteId', '17']);
-            var d=document, g=d.createElement('script'),
-        s=d.getElementsByTagName('script')[0];
-            g.async=true; g.src=u+'matomo.js'; s.parentNode.insertBefore(g,s);
-          })();
-        }
-        if ('requestIdleCallback' in window) {
-          window.requestIdleCallback(loadMatomo, { timeout: 3000 });
-        } else {
-          window.addEventListener('load', loadMatomo, { once: true });
-        }
-      </script>
+      <script data-analytics="matomo">${matomoTracker}</script>
       <!-- End Matomo Code -->
     </head>
     <body <%~ it.bodyAttributes %>>
@@ -74,4 +55,5 @@ module.exports = {
       </script>
     <% } %>
   </html>`,
+  matomoRouteTracking: path.join(__dirname, 
'../shared/matomo-route-tracking.js'),
 };
diff --git a/doc/docusaurus.config.js b/doc/docusaurus.config.js
index b8a6e519e40..6b7eb2a86f6 100644
--- a/doc/docusaurus.config.js
+++ b/doc/docusaurus.config.js
@@ -1,6 +1,6 @@
 const fs = require('fs');
 const path = require('path');
-const { ssrTemplate } = require('../config/ssrTemplate');
+const { ssrTemplate, matomoRouteTracking } = require('../config/ssrTemplate');
 
 /**
  * Give the newest release an explicit versioned path so it stops owning the
@@ -321,6 +321,7 @@ module.exports = {
     },
   ],
   stylesheets: [],
+  clientModules: [matomoRouteTracking],
   /**
    * We need to check if we build site for preview env,
    * or preview site will load static assets from the asf-site branch.
diff --git a/next/MIGRATION.md b/next/MIGRATION.md
index 3aa52c3d180..ebba371d1a7 100644
--- a/next/MIGRATION.md
+++ b/next/MIGRATION.md
@@ -14,7 +14,7 @@ The site is *already* served as static files from the 
`asf-site` branch by ASF h
 
 Regenerate the identical URL space with [Astro](https://astro.build) in static 
output mode:
 
-- **Zero client JS.** Pages are plain HTML + one shared 2.7 KB gz stylesheet. 
(Single exception: `/edit/`, a redirector that inherently needs 3 lines of 
inline JS. Algolia DocSearch and the kapa.ai widget can be re-added as 
progressive enhancements if wanted — they are additive `<script>` tags, not 
framework requirements.)
+- **Zero framework JS.** Pages are plain HTML + one shared 2.7 KB gz 
stylesheet. (Exceptions: the ASF Matomo snippet shared with the Docusaurus 
template via `shared/matomo-tracker.js`, and `/edit/`, a redirector that 
inherently needs 3 lines of inline JS. Algolia DocSearch and the kapa.ai widget 
can be re-added as progressive enhancements if wanted — they are additive 
`<script>` tags, not framework requirements.)
 - **Same content sources.** Blog/learning-center/articles markdown lives in 
the repo as today; project docs are synced from apache/apisix and the six 
sub-project repos exactly as `sync-docs.js` does now 
(`scripts/sync-content.mjs` replaces it, ~120 lines).
 - **Same deploy.** `dist/` → `asf-site` branch → ASF httpd. `.htaccess` 
redirects, staging profile, CI trigger cadence: all unchanged.
 - **Same URLs.** Verified mechanically, not by hand — see below.
@@ -35,7 +35,7 @@ Regenerate the identical URL space with 
[Astro](https://astro.build) in static o
 | Pages built | 1368 (654 EN + 640 ZH sitemap URLs + versionless extras) in 
**~18 s** on a laptop |
 | Homepage transfer | 2.6 KB gz HTML + 2.7 KB gz CSS ≈ **5.3 KB** vs ~200 KB+ 
today (≈40×) |
 | Docs page transfer | 7.3 KB gz HTML + shared CSS |
-| Client JS | 0 bytes on all indexable pages |
+| Client JS | 0 bytes of framework JS on all indexable pages; only the ~0.5 KB 
gz inline Matomo snippet |
 | SEO surface | per-page canonical, `hreflang` en/zh-CN/x-default, meta 
description, OG/Twitter, JSON-LD (WebSite/Organization + 
BlogPosting/TechArticle/FAQPage), robots.txt, split EN/ZH sitemaps — same shape 
as today |
 
 URL rules that had to be reverse-engineered and are now encoded in 
`src/lib/content.ts` (each verified against the live sitemap):
diff --git a/next/README.md b/next/README.md
index ffbfdd687ab..e77eac43f75 100644
--- a/next/README.md
+++ b/next/README.md
@@ -6,8 +6,8 @@ Prototype rebuild of 
[apisix.apache.org](https://apisix.apache.org) as a **fully
 
 | | Current (Docusaurus ×4 workspaces) | This prototype (Astro) |
 |---|---|---|
-| Homepage first-load transfer | ~350 KB HTML + ~120 KB gz JS + 36 KB gz CSS | 
**2.6 KB gz HTML + 2.7 KB gz CSS, 0 JS** |
-| Client-side JS | React SPA per workspace (stale-manifest 404s) | **None** 
(one inline snippet on `/edit/` only) |
+| Homepage first-load transfer | ~350 KB HTML + ~120 KB gz JS + 36 KB gz CSS | 
**2.6 KB gz HTML + 2.7 KB gz CSS, 0 framework JS** |
+| Client-side JS | React SPA per workspace (stale-manifest 404s) | **No 
framework JS** — the shared ASF Matomo snippet (`shared/matomo-tracker.js`, 
~0.5 KB gz inline) on every page, plus one inline snippet on `/edit/` |
 | Build | Node 16, 4 Docusaurus builds, tens of minutes | Node 22, **1368 
pages in ~18 s** |
 | URL parity vs live sitemaps | — | **1294/1294, zero missing** |
 
diff --git a/next/src/layouts/Base.astro b/next/src/layouts/Base.astro
index 2ee05dfc82b..21ad7825758 100644
--- a/next/src/layouts/Base.astro
+++ b/next/src/layouts/Base.astro
@@ -2,6 +2,7 @@
 import '../styles/global.css';
 import Header from '../components/Header.astro';
 import Footer from '../components/Footer.astro';
+import matomoTracker from '../../../shared/matomo-tracker.js?raw';
 import {
   SITE,
   SITE_NAME,
@@ -29,7 +30,7 @@ interface Props {
   /** Open Graph object type; article pages pass "article". */
   ogType?: 'website' | 'article';
   noindex?: boolean;
-  /** Load Algolia DocSearch (docs pages only — every other page stays 
zero-JS). */
+  /** Load Algolia DocSearch on docs pages. */
   search?: boolean;
   /** Cross-site canonical embedded in upstream docs (docs.api7.ai hub pages). 
*/
   canonicalOverride?: string;
@@ -104,6 +105,7 @@ const allJsonLd = [...ORG_JSONLD, ...jsonLd];
       <link rel="stylesheet" 
href="https://cdn.jsdelivr.net/npm/@docsearch/css@3"; />
     </>
   )}
+  <script is:inline data-analytics="matomo" set:html={matomoTracker} />
 </head>
 <body>
   <Header locale={locale} path={path} search={search} switchPath={switchPath} 
/>
diff --git a/next/tests/e2e/analytics.spec.mjs 
b/next/tests/e2e/analytics.spec.mjs
new file mode 100644
index 00000000000..ecd0e300c35
--- /dev/null
+++ b/next/tests/e2e/analytics.spec.mjs
@@ -0,0 +1,114 @@
+import { expect, test } from '@playwright/test';
+
+const PRODUCTION = 'https://apisix.apache.org';
+const baseURL = (process.env.PLAYWRIGHT_BASE_URL ?? 
'http://127.0.0.1:4321').replace(/\/$/, '');
+
+const matomoStub = `(() => {
+  const commands = [];
+  window.__matomoCommands = commands;
+  (window._paq || []).forEach((command) => commands.push(command));
+  window._paq = { push: (...pushed) => pushed.forEach((command) => 
commands.push(command)) };
+})();`;
+
+async function serveAsProduction(page) {
+  await page.route('https://analytics.apache.org/**', async (route) => {
+    if (new URL(route.request().url()).pathname === '/matomo.js') {
+      await route.fulfill({ contentType: 'text/javascript', body: matomoStub 
});
+    } else {
+      await route.fulfill({ status: 204, body: '' });
+    }
+  });
+  await page.route(`${PRODUCTION}/**`, async (route) => {
+    const { pathname, search } = new URL(route.request().url());
+    const response = await route.fetch({ url: `${baseURL}${pathname}${search}` 
});
+    await route.fulfill({ response });
+  });
+}
+
+test.afterEach(async ({ page }) => {
+  await page.unrouteAll({ behavior: 'ignoreErrors' });
+});
+
+const recorded = (page) => page.waitForFunction(() => window.__matomoCommands, 
null, { timeout: 10_000 })
+  .then(() => page.evaluate(() => window.__matomoCommands));
+
+const pageViews = (commands) => {
+  const views = [];
+  let pending = {};
+  commands.forEach(([name, value]) => {
+    if (name === 'setCustomUrl') pending.url = value;
+    if (name === 'setDocumentTitle') pending.title = value;
+    if (name === 'setReferrerUrl') pending.referrer = value;
+    if (name === 'trackPageView') {
+      views.push(pending);
+      pending = { ...pending, referrer: undefined };
+    }
+  });
+  return views;
+};
+
+test('Astro pages send one cookieless page view to site 17', async ({ page, 
context }) => {
+  await serveAsProduction(page);
+  for (const path of ['/', '/zh/', '/blog/', '/integrations/redis/']) {
+    await page.goto(`${PRODUCTION}${path}`);
+    const commands = await recorded(page);
+    expect(commands).toContainEqual(['disableCookies']);
+    expect(commands).toContainEqual(['setSiteId', '17']);
+    expect(commands).toContainEqual(['setTrackerUrl', 
'https://analytics.apache.org/matomo.php']);
+    expect(pageViews(commands)).toEqual([
+      { url: `${PRODUCTION}${path}`, title: await page.title() },
+    ]);
+  }
+  expect(await context.cookies()).toEqual([]);
+});
+
+test('non-production hosts load no tracker', async ({ page }) => {
+  const requests = [];
+  await page.route('https://analytics.apache.org/**', async (route) => {
+    requests.push(route.request().url());
+    await route.abort();
+  });
+  await page.goto('/');
+  await page.waitForTimeout(3_500);
+  expect(await page.evaluate(() => window._paq)).toBeUndefined();
+  expect(requests).toEqual([]);
+});
+
+test('Docusaurus client-side navigation is tracked once per page', async ({ 
page }) => {
+  test.setTimeout(90_000);
+  test.skip(
+    process.env.EXPECT_DOCUSARUS_ROUTES !== 'true'
+      || test.info().project.name !== 'desktop-chrome',
+    'Docusaurus archives only exist in the final overlaid tree',
+  );
+
+  await serveAsProduction(page);
+  const start = `${PRODUCTION}/docs/apisix/next/plugins/cors/`;
+  await page.goto(start);
+  await expect(page.locator('#__docusaurus')).toHaveCount(1);
+  const startTitle = await page.title();
+  expect(pageViews(await recorded(page))).toEqual([{ url: start, title: 
startTitle }]);
+
+  const anchor = page.locator('.table-of-contents a[href^="#"]').first();
+  await anchor.click();
+  await expect(page).toHaveURL(/#/);
+
+  const target = page
+    .locator('.menu 
a.menu__link[href^="/docs/apisix/next/"]:not([href*="/plugins/cors/"]):visible')
+    .first();
+  const targetPath = await target.getAttribute('href');
+  await target.click();
+  await expect(page).toHaveURL(`${PRODUCTION}${targetPath}`);
+  await expect.poll(async () => pageViews(await 
recorded(page)).length).toBe(2);
+
+  const [, view] = pageViews(await recorded(page));
+  expect(view.url).toBe(`${PRODUCTION}${targetPath}`);
+  expect(view.referrer).toBe(start);
+  expect(view.title).toBe(await page.title());
+  expect(view.title).not.toBe(startTitle);
+
+  await page.goBack();
+  await expect(page).toHaveURL(/\/plugins\/cors\//);
+  await expect.poll(async () => pageViews(await 
recorded(page)).length).toBe(3);
+  expect(pageViews(await recorded(page))[2].title).toBe(startTitle);
+});
diff --git a/next/tests/e2e/ecosystem-pages.spec.mjs 
b/next/tests/e2e/ecosystem-pages.spec.mjs
index 1387a82a389..20f9347a8a4 100644
--- a/next/tests/e2e/ecosystem-pages.spec.mjs
+++ b/next/tests/e2e/ecosystem-pages.spec.mjs
@@ -163,7 +163,7 @@ test('Redis detail pages expose translations, 
relationships, and source-review b
     .toHaveAttribute('href', 'https://apisix.apache.org/integrations/redis/');
   await expect(page.locator('link[rel="alternate"][hreflang="zh"]'))
     .toHaveAttribute('href', 
'https://apisix.apache.org/zh/integrations/redis/');
-  await 
expect(page.locator('script:not([type="application/ld+json"])')).toHaveCount(0);
+  await 
expect(page.locator('script:not([type="application/ld+json"]):not([data-analytics="matomo"])')).toHaveCount(0);
   const schema = await jsonLd(page);
   expect(schema.some((item) => item['@type'] === 'TechArticle')).toBeTruthy();
   expect(schema.some((item) => item['@type'] === 
'BreadcrumbList')).toBeTruthy();
diff --git a/shared/matomo-route-tracking.js b/shared/matomo-route-tracking.js
new file mode 100644
index 00000000000..8aefbe3ad39
--- /dev/null
+++ b/shared/matomo-route-tracking.js
@@ -0,0 +1,19 @@
+/* eslint-disable no-underscore-dangle */
+export default {
+  onRouteUpdate({ location, previousLocation }) {
+    if (typeof window === 'undefined' || !window._paq || !previousLocation) 
return;
+    if (location.pathname === previousLocation.pathname
+      && location.search === previousLocation.search) return;
+
+    const url = window.location.href;
+    const referrer = 
`${window.location.origin}${previousLocation.pathname}${previousLocation.search}`;
+    window.requestAnimationFrame(() => setTimeout(() => {
+      const queue = window._paq;
+      queue.push(['setReferrerUrl', referrer]);
+      queue.push(['setCustomUrl', url]);
+      queue.push(['setDocumentTitle', document.title]);
+      queue.push(['trackPageView']);
+      queue.push(['enableLinkTracking']);
+    }, 0));
+  },
+};
diff --git a/shared/matomo-tracker.js b/shared/matomo-tracker.js
new file mode 100644
index 00000000000..01e13c566c3
--- /dev/null
+++ b/shared/matomo-tracker.js
@@ -0,0 +1,30 @@
+/* eslint-disable no-underscore-dangle */
+(() => {
+  if (window.location.hostname !== 'apisix.apache.org') return;
+  const trackerUrl = 'https://analytics.apache.org/';
+  window._paq = window._paq || [];
+  const queue = window._paq;
+  /* tracker methods like "setCustomDimension" should be called before
+  "trackPageView" */
+  /* We explicitly disable cookie tracking to avoid privacy issues */
+  queue.push(['disableCookies']);
+  queue.push(['setTrackerUrl', `${trackerUrl}matomo.php`]);
+  queue.push(['setSiteId', '17']);
+  queue.push(['setCustomUrl', window.location.href]);
+  queue.push(['setDocumentTitle', document.title]);
+  queue.push(['trackPageView']);
+  queue.push(['enableLinkTracking']);
+
+  const loadMatomo = () => {
+    const script = document.createElement('script');
+    script.async = true;
+    script.src = `${trackerUrl}matomo.js`;
+    document.head.appendChild(script);
+  };
+
+  if ('requestIdleCallback' in window) {
+    window.requestIdleCallback(loadMatomo, { timeout: 3000 });
+  } else {
+    window.addEventListener('load', loadMatomo, { once: true });
+  }
+})();
diff --git a/website/docusaurus.config.js b/website/docusaurus.config.js
index 2e8a2136ad6..c3777cead89 100644
--- a/website/docusaurus.config.js
+++ b/website/docusaurus.config.js
@@ -1,4 +1,4 @@
-const { ssrTemplate } = require('../config/ssrTemplate');
+const { ssrTemplate, matomoRouteTracking } = require('../config/ssrTemplate');
 
 module.exports = {
   title: 'Apache APISIX',
@@ -188,6 +188,7 @@ module.exports = {
     },
   ],
   stylesheets: [],
+  clientModules: [matomoRouteTracking],
   /**
    * We need to check if we build site for preview env,
    * or preview site will load static assets from the asf-site branch.

Reply via email to