Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions .github/workflows/frontstage-pages.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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

Expand Down
79 changes: 79 additions & 0 deletions apps/presentation/site/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,3 +58,82 @@ 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. The Google-issued verification file
`public/google5a493c86e5bde9bc.html` is copied unchanged to the site root; keep it
published to retain ownership verification. This file does not load analytics
or collect visitor data, and verification is independent of GA4 consent.
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.
A bilingual notice explains the use of Google Analytics cookies and links to
Google's data-use information. No Google tag, measurement cookie or event is
initialized before the visitor chooses **Allow analytics**. **Reject** keeps
measurement off. Both choices are remembered in local storage and can be changed
through **Analytics preferences** in the page footer. Withdrawal immediately
disables collection, clears this stream's GA cookies and reloads the page to
unload the tag and its timers. It does not erase data already sent to Google.
When storage is unavailable, the choice applies to the current page only.

The loader honors Do Not Track and Global Privacy Control even after a previous
grant, and disables Google signals and advertising consent. The browser smoke
covers an ordinary visitor before choosing, rejection, grant and withdrawal,
including remembered choices and cookie cleanup. Google requests are intercepted
in tests; production collection still needs a real post-deployment check.

| 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.
1 change: 1 addition & 0 deletions apps/presentation/site/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="referrer" content="strict-origin-when-cross-origin" />
<meta name="theme-color" content="#fafafa" />
<meta
name="description"
Expand Down
3 changes: 2 additions & 1 deletion apps/presentation/site/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@
"license": "Apache-2.0",
"type": "module",
"scripts": {
"build": "tsc --noEmit && vite build",
"build": "tsc --noEmit && node scripts/build.mjs",
"smoke:seo": "node scripts/seo-smoke.mjs",
"dev": "vite --host 127.0.0.1",
"preview": "vite preview --host 127.0.0.1"
},
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
google-site-verification: google5a493c86e5bde9bc.html
34 changes: 34 additions & 0 deletions apps/presentation/site/scripts/add-analytics.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
// Run on the fully assembled Pages artifact, after MkDocs and case restoration.
import { copyFile, mkdir, readFile, readdir, rm, writeFile } from "node:fs/promises";
import { resolve } from "node:path";

const directory = resolve(process.argv[2] ?? "output/frontstage-pages/site");
const id = process.env.LOOPX_GA_MEASUREMENT_ID?.trim() ?? "";
if (id && !/^G-[A-Z0-9]+$/.test(id)) throw new Error("LOOPX_GA_MEASUREMENT_ID must be a GA4 G- measurement ID");
const marker = /(?:<script\b[^>]*data-loopx-analytics[^>]*><\/script>|<link\b[^>]*data-loopx-analytics[^>]*>)\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(/<link\s+rel="canonical"\s+href="([^"]+)"/i)?.[1];
if (id && canonical?.startsWith("https://loopx-project.github.io/loopx/") &&
!/<meta[^>]+(?:noindex|http-equiv="refresh")/i.test(html)) {
html = html.replace("</head>", `<link rel="stylesheet" data-loopx-analytics href="/loopx/site-assets/analytics-consent.css">\n<script defer data-loopx-analytics data-measurement-id="${id}" src="/loopx/site-assets/analytics.js"></script>\n</head>`);
count++;
}
if (html !== source) await writeFile(file, html);
}
}
await visit(directory);
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");
138 changes: 138 additions & 0 deletions apps/presentation/site/scripts/analytics-client.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
// 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;
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; }
};
let choice = readChoice();
let started = false;
const panel = document.createElement("section");
panel.id = "loopx-analytics-consent";
panel.setAttribute("aria-label", "Analytics preferences");
panel.innerHTML = `<p></p><a href="https://policies.google.com/technologies/partner-sites" target="_blank" rel="noopener"></a><div><button type="button" data-consent="granted"></button><button type="button" data-consent="denied"></button></div>`;
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);
};
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 });
};
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();
}
}
});
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 });
});
}
})();
38 changes: 38 additions & 0 deletions apps/presentation/site/scripts/analytics-consent.css
Original file line number Diff line number Diff line change
@@ -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; }
}
Loading
Loading