From e242cf9d8fda29f95bf1c8d107814d8d67a5b28e Mon Sep 17 00:00:00 2001
From: huangruiteng <14976749+huangruiteng@users.noreply.github.com>
Date: Fri, 25 Sep 2026 13:10:24 +0800
Subject: [PATCH 1/4] feat(site): prerender public pages and measure discovery
paths
Signed-off-by: huangruiteng <14976749+huangruiteng@users.noreply.github.com>
---
.github/workflows/frontstage-pages.yml | 15 ++
apps/presentation/site/README.md | 65 ++++++
apps/presentation/site/index.html | 1 +
apps/presentation/site/package.json | 3 +-
.../site/scripts/add-analytics.mjs | 32 +++
.../site/scripts/analytics-client.js | 60 ++++++
.../site/scripts/analytics-smoke.mjs | 88 ++++++++
apps/presentation/site/scripts/build.mjs | 78 +++++++
apps/presentation/site/scripts/seo-smoke.mjs | 50 +++++
apps/presentation/site/src/App.tsx | 109 ++++------
apps/presentation/site/src/LhtbBrief.tsx | 26 +--
.../site/src/SweMarathonBrief.tsx | 16 +-
apps/presentation/site/src/page-metadata.ts | 38 ++++
apps/presentation/site/src/render-static.tsx | 11 +
apps/presentation/site/src/styles.css | 198 +++++++-----------
.../site/src/usePublicPageNavigation.ts | 23 +-
.../dashboard-frontstage-browser-smoke.mjs | 12 ++
examples/export-frontstage-share-bundle.mjs | 10 +-
examples/frontstage-share-bundle-smoke.mjs | 23 +-
19 files changed, 613 insertions(+), 245 deletions(-)
create mode 100644 apps/presentation/site/scripts/add-analytics.mjs
create mode 100644 apps/presentation/site/scripts/analytics-client.js
create mode 100644 apps/presentation/site/scripts/analytics-smoke.mjs
create mode 100644 apps/presentation/site/scripts/build.mjs
create mode 100644 apps/presentation/site/scripts/seo-smoke.mjs
create mode 100644 apps/presentation/site/src/page-metadata.ts
create mode 100644 apps/presentation/site/src/render-static.tsx
diff --git a/.github/workflows/frontstage-pages.yml b/.github/workflows/frontstage-pages.yml
index bf15bf77bf..03a9445be9 100644
--- a/.github/workflows/frontstage-pages.yml
+++ b/.github/workflows/frontstage-pages.yml
@@ -29,6 +29,7 @@ on:
- "examples/export-frontstage-share-bundle.mjs"
- "examples/blog-bilingual-index-smoke.mjs"
- "examples/frontstage-share-bundle-smoke.mjs"
+ - "examples/dashboard-frontstage-browser-smoke.mjs"
- "examples/dev-book-browser-smoke.mjs"
- "examples/readme-star-history-smoke.py"
- "examples/frontstage-stargazer-fetch-smoke.py"
@@ -67,6 +68,7 @@ on:
- "examples/export-frontstage-share-bundle.mjs"
- "examples/blog-bilingual-index-smoke.mjs"
- "examples/frontstage-share-bundle-smoke.mjs"
+ - "examples/dashboard-frontstage-browser-smoke.mjs"
- "examples/dev-book-browser-smoke.mjs"
- "examples/readme-star-history-smoke.py"
- "examples/frontstage-stargazer-fetch-smoke.py"
@@ -223,6 +225,19 @@ jobs:
- name: Restore public case pages after documentation builds
run: node examples/export-frontstage-share-bundle.mjs --restore-case-pages --out-dir output/frontstage-pages
+ - name: Validate analytics opt-in and event boundaries
+ run: node apps/presentation/site/scripts/analytics-smoke.mjs output/frontstage-pages/site
+
+ - name: Configure public website analytics
+ env:
+ # Repository variable: public GA4 stream identifier, not an API secret.
+ # PR previews never send production analytics.
+ LOOPX_GA_MEASUREMENT_ID: ${{ github.event_name != 'pull_request' && vars.LOOPX_GA_MEASUREMENT_ID || '' }}
+ run: node apps/presentation/site/scripts/add-analytics.mjs output/frontstage-pages/site
+
+ - name: Validate search publication
+ run: node apps/presentation/site/scripts/seo-smoke.mjs output/frontstage-pages/site --with-docs
+
- name: Validate homepage navigation and retired links in the published site
run: LOOPX_PUBLIC_SITE_DIR="$PWD/output/frontstage-pages/site" npm --prefix apps/presentation/dashboard run smoke:frontstage-browser
diff --git a/apps/presentation/site/README.md b/apps/presentation/site/README.md
index 715090a406..fddc64ef52 100644
--- a/apps/presentation/site/README.md
+++ b/apps/presentation/site/README.md
@@ -58,3 +58,68 @@ not raw session logs. The full-screen viewer bundles only the two explicit
`docs/assets/long-running-loop-*-trajectory.png` files. The site must not consume
live LoopX state, local status feeds, private registries, raw logs, or write
APIs.
+
+## Search publication
+
+`npm run build` compiles the browser app and prerenders the homepage and the
+SWE-Marathon/LHTB briefs from the same React components. Each route ships its
+own body, title, description, canonical URL and social metadata before any
+JavaScript executes. The shared metadata module also updates translated titles
+and descriptions when readers switch language. The English URL is canonical;
+`?lang=zh` remains a client-rendered translation, while Blog translations have
+separate static URLs and reciprocal language alternates.
+
+The build discovers canonical editorial URLs for `sitemap-pages.xml` and writes
+`sitemap.xml`, which also references the three MkDocs sitemaps. The complete
+index is ready only after the Pages workflow builds the documentation. Submit
+`https://loopx-project.github.io/loopx/sitemap.xml` in the verified Search Console
+property after deployment. A project-level `/loopx/robots.txt` would not control
+crawling: robots rules belong at the origin root, outside this Pages artifact.
+
+Public outbound links send at most the origin to other HTTPS sites through
+`strict-origin-when-cross-origin`; new tabs retain `noopener`. This preserves a
+referral signal without sending query strings or adding tracking identifiers to outbound links. Referrals do not reveal the search query or establish
+conversion attribution. Search Console access and indexing are external steps;
+a successful build does not establish ranking or traffic improvement.
+
+Run `npm run smoke:seo` and `node scripts/analytics-smoke.mjs` after a local build (the browser smoke uses the dashboard Playwright dependency). The Pages workflow also runs it on
+the complete artifact with `--with-docs`. It checks static content, route-specific
+metadata, sitemap targets and public asset paths. Preview with `npm run preview`.
+
+## Google Analytics 4
+
+Set the GitHub repository variable `LOOPX_GA_MEASUREMENT_ID` to the website's
+real GA4 web stream ID (`G-…`). The Pages workflow injects measurement into
+canonical public pages after all builds. An unset variable disables analytics;
+PR artifacts and localhost previews do not send production events. Remove the
+variable and redeploy to disable it. No ID or GA API secret is hard-coded.
+
+In the GA4 web stream, disable **Enhanced measurement**: this integration sends
+explicit pageviews and conversion-intent events, including during MkDocs instant
+navigation. Leaving automatic history, form or outbound collection on can create
+duplicate events and collect fields outside this site's explicit event schema.
+The loader honors Do Not Track and Global Privacy Control and disables Google
+signals and ad personalization. Normal GA4 first-party measurement cookies may
+be used when measurement is active; deployment owners retain responsibility for
+their site's consent configuration and privacy notice.
+
+| Event | Meaning |
+| --- | --- |
+| `page_view` | A canonical public page was opened; hash and locale-only changes do not add views. |
+| `setup_open` | Opened the homepage setup dialog. |
+| `setup_copy` | Successfully copied an Agent prompt or Shell setup command (`setup_method`). |
+| `showcase_open` | Followed the homepage demonstration CTA. |
+| `github_click` | Clicked a repository or DSH plugin link (`destination`). |
+| `docs_open` | Followed a link into documentation. |
+
+These events measure interest, not completed installs or active LoopX projects.
+Use GA4 Realtime to verify one real visit and setup action after publication,
+then mark `setup_copy` as a key event if it matches the reporting goal. Compare
+organic landing-page visits and setup-copy/GitHub-click rates over consistent
+windows. Search Console supplies website query/impression data; GitHub Traffic
+supplies repository referrals. Neither dataset alone identifies the full path.
+
+Page locations use canonical paths and referrers use only their origin. The
+integration does not send raw query strings, fragments, task text, clipboard
+contents, local workspace state or operator dashboard interactions. The current
+implementation does not attribute UTM campaigns separately.
diff --git a/apps/presentation/site/index.html b/apps/presentation/site/index.html
index 3dd2607e74..caae85a606 100644
--- a/apps/presentation/site/index.html
+++ b/apps/presentation/site/index.html
@@ -3,6 +3,7 @@
+
]*data-loopx-analytics[^>]*><\/script>\s*/g;
+let count = 0;
+async function visit(dir) {
+ for (const entry of await readdir(dir, { withFileTypes: true })) {
+ const file = resolve(dir, entry.name);
+ if (entry.isDirectory()) { await visit(file); continue; }
+ if (!entry.name.endsWith(".html")) continue;
+ const source = await readFile(file, "utf8");
+ let html = source.replace(marker, "");
+ const canonical = html.match(/ ]+(?:noindex|http-equiv="refresh")/i.test(html)) {
+ html = html.replace("", `\n`);
+ count++;
+ }
+ if (html !== source) await writeFile(file, html);
+ }
+}
+await visit(directory);
+const client = resolve(directory, "site-assets/analytics.js");
+if (count) {
+ await mkdir(resolve(directory, "site-assets"), { recursive: true });
+ await copyFile(new URL("./analytics-client.js", import.meta.url), client);
+} else await rm(client, { force: true });
+console.log(id ? `GA4 configured on ${count} canonical public pages` : "GA4 disabled: measurement ID is unset");
diff --git a/apps/presentation/site/scripts/analytics-client.js b/apps/presentation/site/scripts/analytics-client.js
new file mode 100644
index 0000000000..fed7f8b038
--- /dev/null
+++ b/apps/presentation/site/scripts/analytics-client.js
@@ -0,0 +1,60 @@
+// Public website measurement only. Never loaded into the local workspace app.
+(() => {
+ const id = document.currentScript?.dataset.measurementId;
+ if (!/^G-[A-Z0-9]+$/.test(id ?? "") || location.hostname !== "loopx-project.github.io" ||
+ navigator.doNotTrack === "1" || navigator.globalPrivacyControl || window.__loopxAnalyticsActive) return;
+ window.__loopxAnalyticsActive = true;
+ window.dataLayer = window.dataLayer || [];
+ function gtag() { window.dataLayer.push(arguments); }
+ const originOnly = (url) => { try { return new URL(url).origin; } catch { return ""; } };
+ let previous = "";
+ const pageFields = () => {
+ const canonical = document.querySelector('link[rel="canonical"]')?.href;
+ if (!canonical) return null;
+ const url = new URL(canonical);
+ if (url.origin !== "https://loopx-project.github.io" || !url.pathname.startsWith("/loopx/")) return null;
+ return { page_location: url.origin + url.pathname, page_referrer: originOnly(document.referrer),
+ page_title: document.title, language: new URLSearchParams(location.search).get("lang") === "zh" ? "zh-CN" : document.documentElement.lang };
+ };
+ const initial = pageFields();
+ if (!initial) return;
+ gtag("js", new Date());
+ gtag("set", initial);
+ gtag("config", id, { ...initial, send_page_view: false,
+ allow_google_signals: false, allow_ad_personalization_signals: false });
+ const pageView = () => {
+ const fields = pageFields();
+ if (!fields || fields.page_location === previous) return;
+ previous = fields.page_location;
+ gtag("set", fields);
+ gtag("event", "page_view", fields);
+ };
+ pageView();
+ const tag = document.createElement("script");
+ tag.async = true;
+ tag.src = `https://www.googletagmanager.com/gtag/js?id=${id}`;
+ document.head.append(tag);
+ // Material's instant navigation updates the canonical element. Locale and
+ // fragment changes on a React page must not inflate pageview counts.
+ new MutationObserver(pageView).observe(document.head, { subtree: true, childList: true, attributes: true, attributeFilter: ["href"] });
+ const event = (name, fields = {}) => {
+ const page = pageFields();
+ if (page) gtag("event", name, { ...page, ...fields });
+ };
+ document.addEventListener("click", (e) => {
+ const target = e.target instanceof Element ? e.target : null;
+ const marked = target?.closest("[data-analytics-event]")?.dataset.analyticsEvent;
+ if (["setup_open", "showcase_open"].includes(marked)) event(marked);
+ const anchor = target?.closest("a[href]");
+ if (!anchor) return;
+ const url = new URL(anchor.href);
+ if (url.hostname === "github.com" && /^\/(?:loopx-project|huangruiteng)\/loopx(?:\/|$)/.test(url.pathname)) {
+ event("github_click", { destination: url.pathname.includes("packages/dsh-loopx-plugin") ? "dsh_plugin" : "repository" });
+ } else if (url.origin === location.origin && url.pathname.startsWith("/loopx/docs/") && url.pathname !== location.pathname) {
+ event("docs_open");
+ }
+ });
+ window.addEventListener("loopx:setup-copy", (e) => {
+ if (e.detail === "agent" || e.detail === "shell") event("setup_copy", { setup_method: e.detail });
+ });
+})();
diff --git a/apps/presentation/site/scripts/analytics-smoke.mjs b/apps/presentation/site/scripts/analytics-smoke.mjs
new file mode 100644
index 0000000000..1e4d20706c
--- /dev/null
+++ b/apps/presentation/site/scripts/analytics-smoke.mjs
@@ -0,0 +1,88 @@
+import assert from "node:assert/strict";
+import { spawnSync } from "node:child_process";
+import { cp, mkdtemp, readFile, rm, access } from "node:fs/promises";
+import { createRequire } from "node:module";
+import { tmpdir } from "node:os";
+import { resolve, extname } from "node:path";
+import { fileURLToPath } from "node:url";
+
+const root = fileURLToPath(new URL("../", import.meta.url));
+const require = createRequire(resolve(root, "../dashboard/package.json"));
+const { chromium } = require("playwright");
+const out = await mkdtemp(resolve(tmpdir(), "loopx-analytics-smoke-"));
+const inject = (id) => spawnSync(process.execPath, [resolve(root, "scripts/add-analytics.mjs"), out], {
+ env: { ...process.env, LOOPX_GA_MEASUREMENT_ID: id }, encoding: "utf8",
+});
+let browser;
+try {
+ await cp(resolve(process.argv[2] ?? resolve(root, "dist")), out, { recursive: true });
+ const initial = await readFile(resolve(out, "index.html"), "utf8");
+ assert.equal(inject("").status, 0);
+ assert.equal(await readFile(resolve(out, "index.html"), "utf8"), initial, "disabled mode leaves HTML unchanged");
+ assert.notEqual(inject('invalid">` : ""}
+`;
+ const html = template
+ .replace(/[\s\S]*?<\/title>\s*/g, "")
+ .replace(/ \s*/g, "")
+ .replace("", `${head}`)
+ .replace('
', () => `${render(key)}
`);
+ const target = resolve(outDir, page.path, "index.html");
+ await mkdir(dirname(target), { recursive: true });
+ await writeFile(target, html);
+ }
+ // Discover static editorial pages from the built site, including both locales.
+ // Do not include operator shells, redirects or URLs absent from this build.
+ const urls = new Set();
+ async function collect(directory) {
+ for (const entry of await readdir(directory, { withFileTypes: true })) {
+ const file = resolve(directory, entry.name);
+ if (entry.isDirectory()) await collect(file);
+ else if (entry.name.endsWith(".html")) {
+ const html = await readFile(file, "utf8");
+ const canonical = html.match(/ ]+(?:noindex|http-equiv="refresh")/i.test(html)) urls.add(canonical);
+ }
+ }
+ }
+ await collect(outDir);
+ await writeFile(resolve(outDir, "sitemap-pages.xml"), `\n\n${[...urls].sort().map((url) => ` ${escape(url)} `).join("\n")}\n \n`);
+ // MkDocs supplies the three documentation sitemaps in the Pages workflow.
+ const maps = ["sitemap-pages.xml", "docs/sitemap.xml", "docs/book/sitemap.xml", "docs/book/en/sitemap.xml"];
+ await writeFile(resolve(outDir, "sitemap.xml"), `\n\n${maps.map((path) => ` ${siteUrl}${path} `).join("\n")}\n \n`);
+} finally {
+ await rm(serverDir, { recursive: true, force: true });
+}
diff --git a/apps/presentation/site/scripts/seo-smoke.mjs b/apps/presentation/site/scripts/seo-smoke.mjs
new file mode 100644
index 0000000000..61c8def650
--- /dev/null
+++ b/apps/presentation/site/scripts/seo-smoke.mjs
@@ -0,0 +1,50 @@
+import assert from "node:assert/strict";
+import { access, readFile } from "node:fs/promises";
+import { resolve } from "node:path";
+import { fileURLToPath } from "node:url";
+
+const directory = resolve(process.argv[2] ?? fileURLToPath(new URL("../dist", import.meta.url)));
+const origin = "https://loopx-project.github.io/loopx/";
+const titles = new Set();
+for (const [path, subject] of [["", "long-running"], ["benchmarks/swe-marathon/", "SWE-Marathon"], ["benchmarks/lhtb/", "LHTB"]]) {
+ const html = await readFile(resolve(directory, path, "index.html"), "utf8");
+ const title = html.match(/(.*?)<\/title>/s)?.[1];
+ assert(title?.toLowerCase().includes(subject.toLowerCase()), `${path}: descriptive title`);
+ assert(!titles.has(title), "page titles must be distinct");
+ titles.add(title);
+ assert.equal((html.match(/)/g) ?? []).length, 1, `${path}: one static primary heading`);
+ assert((html.match(/)/g) ?? []).length >= 2, `${path}: real sections before JavaScript`);
+ assert(html.includes('id="root">') && !html.includes('id="root">'), `${path}: populated React root`);
+ assert.equal((html.match(/rel="canonical"/g) ?? []).length, 1);
+ assert(html.includes(`rel="canonical" href="${origin}${path}"`), `${path}: canonical matches route`);
+ if (!path) {
+ const structured = JSON.parse(html.match(/\n`);
+ html = html.replace("", ` \n\n`);
count++;
}
if (html !== source) await writeFile(file, html);
}
}
await visit(directory);
-const client = resolve(directory, "site-assets/analytics.js");
-if (count) {
- await mkdir(resolve(directory, "site-assets"), { recursive: true });
- await copyFile(new URL("./analytics-client.js", import.meta.url), client);
-} else await rm(client, { force: true });
+for (const [source, name] of [["analytics-client.js", "analytics.js"], ["analytics-consent.css", "analytics-consent.css"]]) {
+ const target = resolve(directory, "site-assets", name);
+ if (count) {
+ await mkdir(resolve(directory, "site-assets"), { recursive: true });
+ await copyFile(new URL(source, import.meta.url), target);
+ } else await rm(target, { force: true });
+}
console.log(id ? `GA4 configured on ${count} canonical public pages` : "GA4 disabled: measurement ID is unset");
diff --git a/apps/presentation/site/scripts/analytics-client.js b/apps/presentation/site/scripts/analytics-client.js
index fed7f8b038..9fe63a89a7 100644
--- a/apps/presentation/site/scripts/analytics-client.js
+++ b/apps/presentation/site/scripts/analytics-client.js
@@ -4,57 +4,135 @@
if (!/^G-[A-Z0-9]+$/.test(id ?? "") || location.hostname !== "loopx-project.github.io" ||
navigator.doNotTrack === "1" || navigator.globalPrivacyControl || window.__loopxAnalyticsActive) return;
window.__loopxAnalyticsActive = true;
- window.dataLayer = window.dataLayer || [];
- function gtag() { window.dataLayer.push(arguments); }
- const originOnly = (url) => { try { return new URL(url).origin; } catch { return ""; } };
- let previous = "";
- const pageFields = () => {
- const canonical = document.querySelector('link[rel="canonical"]')?.href;
- if (!canonical) return null;
- const url = new URL(canonical);
- if (url.origin !== "https://loopx-project.github.io" || !url.pathname.startsWith("/loopx/")) return null;
- return { page_location: url.origin + url.pathname, page_referrer: originOnly(document.referrer),
- page_title: document.title, language: new URLSearchParams(location.search).get("lang") === "zh" ? "zh-CN" : document.documentElement.lang };
+ const storageKey = "loopx.analytics-consent.v1";
+ const readChoice = () => {
+ try {
+ const choice = JSON.parse(localStorage.getItem(storageKey))?.choice;
+ return ["granted", "denied"].includes(choice) ? choice : null;
+ } catch { return null; }
};
- const initial = pageFields();
- if (!initial) return;
- gtag("js", new Date());
- gtag("set", initial);
- gtag("config", id, { ...initial, send_page_view: false,
- allow_google_signals: false, allow_ad_personalization_signals: false });
- const pageView = () => {
- const fields = pageFields();
- if (!fields || fields.page_location === previous) return;
- previous = fields.page_location;
- gtag("set", fields);
- gtag("event", "page_view", fields);
+ let choice = readChoice();
+ let started = false;
+ const panel = document.createElement("section");
+ panel.id = "loopx-analytics-consent";
+ panel.setAttribute("aria-label", "Analytics preferences");
+ panel.innerHTML = `
`;
+ panel.hidden = choice !== null;
+ document.body.append(panel);
+ const settings = document.createElement("button");
+ settings.type = "button";
+ settings.id = "loopx-analytics-settings";
+ settings.onclick = () => { panel.hidden = false; panel.querySelector("button").focus(); };
+ const syncUi = () => {
+ const zh = new URLSearchParams(location.search).get("lang") === "zh" || document.documentElement.lang.startsWith("zh");
+ const labels = zh ? [
+ "经你允许,我们会使用 Google Analytics Cookie 统计访问和使用情况,以改进网站。可随时在页脚修改选择。",
+ "Google 如何使用数据", "允许统计", "拒绝", "统计偏好",
+ ] : [
+ "With your permission, we use Google Analytics cookies to understand visits and usage and improve this site. You can change your choice in the footer.",
+ "How Google uses data", "Allow analytics", "Reject", "Analytics preferences",
+ ];
+ [panel.querySelector("p"), panel.querySelector("a"), ...panel.querySelectorAll("button"), settings].forEach((node, i) => {
+ if (node.textContent !== labels[i]) node.textContent = labels[i];
+ });
+ if (panel.getAttribute("aria-label") !== labels[4]) panel.setAttribute("aria-label", labels[4]);
+ // React startup and Material instant navigation can replace the site footer.
+ const footer = document.querySelector(".site-footer, .bm-footer, .md-footer") || [...document.querySelectorAll("footer")].at(-1) || document.body;
+ if (settings.parentNode !== footer) footer.append(settings);
};
- pageView();
- const tag = document.createElement("script");
- tag.async = true;
- tag.src = `https://www.googletagmanager.com/gtag/js?id=${id}`;
- document.head.append(tag);
- // Material's instant navigation updates the canonical element. Locale and
- // fragment changes on a React page must not inflate pageview counts.
- new MutationObserver(pageView).observe(document.head, { subtree: true, childList: true, attributes: true, attributeFilter: ["href"] });
- const event = (name, fields = {}) => {
- const page = pageFields();
- if (page) gtag("event", name, { ...page, ...fields });
+ const choose = (next) => {
+ choice = next;
+ try { localStorage.setItem(storageKey, JSON.stringify({ choice, at: new Date().toISOString() })); } catch { /* The choice still applies to this page when storage is unavailable. */ }
+ panel.hidden = true;
+ if (choice === "granted") start();
+ else if (started) {
+ // Disable collection immediately, remove measurement cookies, then unload
+ // the tag and its automatic timers. No denied-state pings are needed.
+ window[`ga-disable-${id}`] = true;
+ for (const cookie of document.cookie.split(";")) {
+ const name = cookie.split("=")[0].trim();
+ if (name !== "_ga" && name !== `_ga_${id.slice(2)}`) continue;
+ for (const path of ["/", "/loopx", "/loopx/"]) {
+ for (const domain of ["", `; domain=${location.hostname}`]) {
+ document.cookie = `${name}=; Max-Age=0; path=${path}${domain}`;
+ }
+ }
+ }
+ location.reload();
+ }
+ if (panel.contains(document.activeElement)) settings.focus({ preventScroll: true });
};
- document.addEventListener("click", (e) => {
- const target = e.target instanceof Element ? e.target : null;
- const marked = target?.closest("[data-analytics-event]")?.dataset.analyticsEvent;
- if (["setup_open", "showcase_open"].includes(marked)) event(marked);
- const anchor = target?.closest("a[href]");
- if (!anchor) return;
- const url = new URL(anchor.href);
- if (url.hostname === "github.com" && /^\/(?:loopx-project|huangruiteng)\/loopx(?:\/|$)/.test(url.pathname)) {
- event("github_click", { destination: url.pathname.includes("packages/dsh-loopx-plugin") ? "dsh_plugin" : "repository" });
- } else if (url.origin === location.origin && url.pathname.startsWith("/loopx/docs/") && url.pathname !== location.pathname) {
- event("docs_open");
+ panel.querySelectorAll("button").forEach((button) => { button.onclick = () => choose(button.dataset.consent); });
+ window.addEventListener("storage", (e) => {
+ if (e.key === storageKey) {
+ const next = readChoice();
+ if (next !== choice) {
+ if (started) window[`ga-disable-${id}`] = true;
+ location.reload();
+ }
}
});
- window.addEventListener("loopx:setup-copy", (e) => {
- if (e.detail === "agent" || e.detail === "shell") event("setup_copy", { setup_method: e.detail });
- });
+ syncUi();
+ new MutationObserver(syncUi).observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ["data-language"] });
+ if (choice === "granted") start();
+
+ function start() {
+ if (started || choice !== "granted") return;
+ started = true;
+ window.dataLayer = window.dataLayer || [];
+ function gtag() { window.dataLayer.push(arguments); }
+ const originOnly = (url) => { try { return new URL(url).origin; } catch { return ""; } };
+ let previous = "";
+ const pageFields = () => {
+ const canonical = document.querySelector('link[rel="canonical"]')?.href;
+ if (!canonical) return null;
+ const url = new URL(canonical);
+ if (url.origin !== "https://loopx-project.github.io" || !url.pathname.startsWith("/loopx/")) return null;
+ return { page_location: url.origin + url.pathname, page_referrer: originOnly(document.referrer),
+ page_title: document.title, language: new URLSearchParams(location.search).get("lang") === "zh" ? "zh-CN" : document.documentElement.lang };
+ };
+ const initial = pageFields();
+ if (!initial) return;
+ gtag("consent", "default", { analytics_storage: "denied", ad_storage: "denied", ad_user_data: "denied", ad_personalization: "denied" });
+ gtag("consent", "update", { analytics_storage: "granted" });
+ gtag("js", new Date());
+ gtag("set", initial);
+ gtag("config", id, { ...initial, send_page_view: false,
+ allow_google_signals: false, allow_ad_personalization_signals: false });
+ const pageView = () => {
+ const fields = pageFields();
+ if (choice !== "granted" || !fields || fields.page_location === previous) return;
+ previous = fields.page_location;
+ gtag("set", fields);
+ gtag("event", "page_view", fields);
+ };
+ pageView();
+ const tag = document.createElement("script");
+ tag.async = true;
+ tag.src = `https://www.googletagmanager.com/gtag/js?id=${id}`;
+ document.head.append(tag);
+ // Material's instant navigation updates the canonical element. Locale and
+ // fragment changes on a React page must not inflate pageview counts.
+ new MutationObserver(pageView).observe(document.head, { subtree: true, childList: true, attributes: true, attributeFilter: ["href"] });
+ const event = (name, fields = {}) => {
+ const page = pageFields();
+ if (page && choice === "granted") gtag("event", name, { ...page, ...fields });
+ };
+ document.addEventListener("click", (e) => {
+ const target = e.target instanceof Element ? e.target : null;
+ const marked = target?.closest("[data-analytics-event]")?.dataset.analyticsEvent;
+ if (["setup_open", "showcase_open"].includes(marked)) event(marked);
+ const anchor = target?.closest("a[href]");
+ if (!anchor) return;
+ const url = new URL(anchor.href);
+ if (url.hostname === "github.com" && /^\/(?:loopx-project|huangruiteng)\/loopx(?:\/|$)/.test(url.pathname)) {
+ event("github_click", { destination: url.pathname.includes("packages/dsh-loopx-plugin") ? "dsh_plugin" : "repository" });
+ } else if (url.origin === location.origin && url.pathname.startsWith("/loopx/docs/") && url.pathname !== location.pathname) {
+ event("docs_open");
+ }
+ });
+ window.addEventListener("loopx:setup-copy", (e) => {
+ if (e.detail === "agent" || e.detail === "shell") event("setup_copy", { setup_method: e.detail });
+ });
+ }
})();
diff --git a/apps/presentation/site/scripts/analytics-consent.css b/apps/presentation/site/scripts/analytics-consent.css
new file mode 100644
index 0000000000..19b5659b17
--- /dev/null
+++ b/apps/presentation/site/scripts/analytics-consent.css
@@ -0,0 +1,38 @@
+#loopx-analytics-consent {
+ position: fixed;
+ inset: auto 16px 16px;
+ z-index: 900;
+ max-width: 960px;
+ margin: 0 auto;
+ padding: 16px 20px;
+ border: 1px solid #dedede;
+ border-radius: 12px;
+ background: #fff;
+ color: #171717;
+ font: 14px/1.5 "Geist", "Inter", "Helvetica Neue", Arial, sans-serif;
+ box-shadow: 0 4px 16px #0000000a;
+ display: flex;
+ align-items: center;
+ gap: 16px;
+}
+#loopx-analytics-consent[hidden] { display: none; }
+#loopx-analytics-consent p { margin: 0; flex: 1; }
+#loopx-analytics-consent a { color: #0070c9; font-size: 12px; text-decoration: underline; }
+#loopx-analytics-consent div { display: flex; gap: 8px; flex-shrink: 0; }
+#loopx-analytics-consent button, #loopx-analytics-settings {
+ min-height: 44px;
+ padding: 8px 14px;
+ border: 1px solid #dedede;
+ border-radius: 6px;
+ background: #fff;
+ color: #171717;
+ font: inherit;
+ cursor: pointer;
+}
+#loopx-analytics-settings { margin: 8px; font-size: 12px; }
+#loopx-analytics-consent button:focus-visible, #loopx-analytics-consent a:focus-visible, #loopx-analytics-settings:focus-visible { outline: 2px solid #0070f3; outline-offset: 3px; }
+@media (max-width: 640px) {
+ #loopx-analytics-consent { inset: auto 12px 12px; flex-wrap: wrap; gap: 12px; padding: 16px; }
+ #loopx-analytics-consent p { flex-basis: 100%; }
+ #loopx-analytics-consent div { margin-left: auto; }
+}
diff --git a/apps/presentation/site/scripts/analytics-smoke.mjs b/apps/presentation/site/scripts/analytics-smoke.mjs
index 1e4d20706c..60c021758d 100644
--- a/apps/presentation/site/scripts/analytics-smoke.mjs
+++ b/apps/presentation/site/scripts/analytics-smoke.mjs
@@ -16,6 +16,7 @@ const inject = (id) => spawnSync(process.execPath, [resolve(root, "scripts/add-a
let browser;
try {
await cp(resolve(process.argv[2] ?? resolve(root, "dist")), out, { recursive: true });
+ assert.equal(inject("").status, 0);
const initial = await readFile(resolve(out, "index.html"), "utf8");
assert.equal(inject("").status, 0);
assert.equal(await readFile(resolve(out, "index.html"), "utf8"), initial, "disabled mode leaves HTML unchanged");
@@ -31,17 +32,30 @@ try {
if (scenario === "dnt") await context.addInitScript(() => Object.defineProperty(navigator, "doNotTrack", { value: "1" }));
if (scenario === "gpc") await context.addInitScript(() => Object.defineProperty(navigator, "globalPrivacyControl", { value: true }));
const tagRequests = [];
+ const collectionRequests = [];
+ if (scenario !== "enabled") await context.addInitScript(() => localStorage.setItem("loopx.analytics-consent.v1", JSON.stringify({ choice: "granted" })));
const origin = scenario === "local" ? "http://localhost" : "https://loopx-project.github.io";
await context.route("**/*", async (route) => {
const url = new URL(route.request().url());
if (url.hostname === "www.googletagmanager.com") {
tagRequests.push(url.href);
- return route.fulfill({ contentType: "text/javascript", body: "" });
+ return route.fulfill({ contentType: "text/javascript", body: `
+ document.cookie = "_ga=fixture; path=/; domain=loopx-project.github.io";
+ document.cookie = "_ga_TEST123456=fixture; path=/";
+ const collect = (args) => { if (args[0] === "event" && !window["ga-disable-G-TEST123456"]) fetch("https://www.google-analytics.com/g/collect", {method:"POST",body:JSON.stringify([...args])}); };
+ window.dataLayer.forEach(collect);
+ const push = window.dataLayer.push.bind(window.dataLayer);
+ window.dataLayer.push = (...rows) => { rows.forEach(collect); return push(...rows); };
+ ` });
+ }
+ if (url.hostname === "www.google-analytics.com") {
+ collectionRequests.push(route.request().postData());
+ return route.fulfill({ status: 204, body: "" });
}
if (url.origin !== origin) return route.abort();
const path = url.pathname.replace(/^\/(?:loopx\/)?/, "");
try {
- const file = resolve(out, path || "index.html");
+ const file = resolve(out, !path || path.endsWith("/") ? path + "index.html" : path);
const contentType = ({ ".html": "text/html", ".js": "text/javascript", ".css": "text/css", ".png": "image/png" })[extname(file)] ?? "application/octet-stream";
await route.fulfill({ contentType, body: await readFile(file) });
} catch { await route.fulfill({ status: 404, body: "Not found" }); }
@@ -55,7 +69,22 @@ try {
await context.close();
continue;
}
- await page.waitForFunction(() => window.dataLayer?.length >= 5);
+ await page.locator("#loopx-analytics-consent").waitFor();
+ // A normal visitor's pre-choice interactions must not even initialize GA.
+ await page.evaluate(() => window.dispatchEvent(new CustomEvent("loopx:setup-copy", { detail: "shell" })));
+ assert.equal(tagRequests.length, 0, "no remote tag before choosing");
+ assert.equal(collectionRequests.length, 0, "no collection before choosing");
+ assert.equal(await page.evaluate(() => window.dataLayer), undefined);
+ assert.equal((await context.cookies()).filter(c => c.name.startsWith("_ga")).length, 0);
+ await page.locator('[data-consent="denied"]').click();
+ await page.reload();
+ await page.locator("h1").waitFor();
+ assert.equal(await page.locator("#loopx-analytics-consent").isVisible(), false, "rejection remembered");
+ assert.equal(tagRequests.length, 0, "rejected reload stays off");
+ await page.locator("#loopx-analytics-settings").click();
+ await page.locator('[data-consent="granted"]').click();
+ await page.waitForFunction(() => window.dataLayer?.length >= 7);
+ await page.waitForFunction(() => document.cookie.includes("_ga="));
assert.equal(tagRequests.length, 1);
await page.locator('.hero [data-analytics-event="setup_open"]').click();
// Only the successful copy path in App dispatches this signal. Invalid
@@ -76,12 +105,35 @@ try {
document.querySelector('link[rel="canonical"]').href = "https://loopx-project.github.io/loopx/docs/";
});
await page.waitForFunction(() => window.dataLayer.filter((r) => r[0] === "event" && r[1] === "page_view").length === 2);
+ assert.equal(collectionRequests.filter(row => JSON.parse(row)[1] === "page_view").length, 2);
+ await page.reload();
+ await page.waitForFunction(() => document.cookie.includes("_ga="));
+ assert.equal(tagRequests.length, 2, "grant remembered across reloads");
+ assert.equal(await page.locator("#loopx-analytics-consent").isVisible(), false);
+ await page.locator("#loopx-analytics-settings").click();
+ const beforeWithdrawal = collectionRequests.length;
+ await Promise.all([page.waitForEvent("load"), page.locator('[data-consent="denied"]').click()]);
+ await page.locator("h1").waitFor();
+ assert.equal(tagRequests.length, 2, "withdrawal unloads tag without reloading it");
+ assert.equal(collectionRequests.length, beforeWithdrawal, "withdrawal sends no events");
+ assert.equal(await page.evaluate(() => window.dataLayer), undefined);
+ assert.equal((await context.cookies()).filter(c => c.name.startsWith("_ga")).length, 0, "GA cookies cleared");
+ // Replacing a footer (as in client rendering / instant navigation) retains
+ // the visitor's settings entry without re-enabling measurement.
+ await page.evaluate(() => document.querySelector(".site-footer").replaceChildren());
+ await page.locator(".site-footer #loopx-analytics-settings").waitFor();
+ if (await access(resolve(out, "docs/index.html")).then(() => true, () => false)) {
+ await page.goto(`${origin}/loopx/docs/`);
+ await page.locator("#loopx-analytics-settings").waitFor();
+ assert.equal(tagRequests.length, 2, "rejection shared with documentation pages");
+ }
await context.close();
}
assert.equal(inject("").status, 0);
assert.equal(await readFile(resolve(out, "index.html"), "utf8"), initial, "disable removes injection");
await assert.rejects(access(resolve(out, "site-assets/analytics.js")));
- console.log("Analytics smoke: opt-in/off, injection idempotence, invalid ID, public-host scope, DNT/GPC, pageviews, locale dedupe, CTA schema and URL redaction passed; no Google traffic sent");
+ await assert.rejects(access(resolve(out, "site-assets/analytics-consent.css")));
+ console.log("Analytics smoke: pre-choice/rejection isolation, grant, remembered choice, withdrawal/cookie cleanup, footer replacement, deployment off, DNT/GPC and event boundaries passed; Google requests intercepted");
} finally {
await browser?.close();
await rm(out, { recursive: true, force: true });