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
5 changes: 5 additions & 0 deletions .github/workflows/pages-build-site.yml
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,11 @@ jobs:

- name: Build
run: pnpm build
env:
# Cloudflare Web Analytics beacon token (public client-side value).
# Set it under Settings → Secrets and variables → Actions → Variables.
# Unset → analytics is tree-shaken out and the site ships tracking-free.
VITE_CF_BEACON_TOKEN: ${{ vars.VITE_CF_BEACON_TOKEN }}

- name: Upload artifact
uses: actions/upload-pages-artifact@v5
Expand Down
48 changes: 48 additions & 0 deletions docs/explorations/2026-07-05-privacy-analytics-cloudflare.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
# 埋点分析:选 Cloudflare Web Analytics 而非 Google Analytics

日期:2026-07-05
分支:`fix/sw-freshness`

## 决策

项目核心卖点是"本地处理、无需服务器、保护隐私"。Google Analytics 与此**直接冲突**:
注入 Google 追踪脚本、设 cookie、把用户行为发给 Google,且在欧盟需 cookie 同意横幅
(界面支持德/法/西/葡等语言 = 有欧洲用户)。故**不用 GA**。

改用 **Cloudflare Web Analytics**:无 cookie、无需同意横幅(GDPR 友好)、免费、一段
beacon script 即可,最不破坏隐私定位。

## 实现

新增 [lib/analytics.ts](../../lib/analytics.ts),在 [index.ts](../../index.ts) 的
`initEmbedApi()` 之后调用 `initAnalytics()`。两道加载条件:

1. **配了 token 才加载**:token 从 `import.meta.env.VITE_CF_BEACON_TOKEN` 读取。未设置 →
直接 return,且 Vite/Rolldown 会把整段 beacon 代码 **tree-shake 移除**(默认部署 = 零
Cloudflare 引用、零外部请求)。token 是公开的客户端值,构建期内联安全。
2. **仅顶层页面加载**:`window.parent !== window` 或 `?embed=/?embedded=` 时不加载——
别人把编辑器嵌进 iframe 时,不该把宿主页面的访客算进我们的统计。

类型:新增 [vite-env.d.ts](../../vite-env.d.ts) 声明 `VITE_CF_BEACON_TOKEN`。

## 如何启用

1. Cloudflare Dashboard → Web Analytics → Add a site(`ranuts.github.io/document/`)→ 拿到
snippet 里的 `token`。
2. 本地:项目根 `.env.local` 写 `VITE_CF_BEACON_TOKEN=xxxx`。
3. CI(`pages-build-site.yml`):Build step 已注入
`env: VITE_CF_BEACON_TOKEN: ${{ vars.VITE_CF_BEACON_TOKEN }}`(本次改动已完成)。**只需**在
仓库 Settings → Secrets and variables → Actions → **Variables** 加 `VITE_CF_BEACON_TOKEN`
(公开值,用 Variable 即可,无需 Secret)。

未做以上任何一步 → 站点行为不变(无埋点,且 beacon 代码被 tree-shake 移除)。

## 验证(chrome-devtools + 构建产物)

| 构建 | 预期 | 实测 |
| --- | --- | --- |
| 无 token(默认) | 不注入 | ✅ bundle 里零 `cloudflareinsights` 引用(死代码消除) |
| 有 token · 顶层 | 注入 beacon | ✅ `data-cf-beacon={"token":...}` 正确 |
| 有 token · `?embed=1` | 不注入 | ✅ beacon 缺席,`embed-mode` class 存在 |

`lint:ts` ✅ / prettier ✅ / `build` ✅。
78 changes: 78 additions & 0 deletions docs/explorations/2026-07-05-sw-stale-build-auto-reload.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
# PPT "字体乱码" 真因 = Service Worker 缓存旧构建 + SW 自动更新修复

日期:2026-07-05
分支:`fix/sw-freshness`(基于 `release/v0.0.4`)

## 现象

用户在生产站 `https://ranuts.github.io/document/` 新建 PPT,标题占位符显示为乱码
`Ajgai rm_bb rgjc`,**再刷新一次就正常**。用户问:"不是已经修过了吗?"

## 排查过程与被推翻的假设

一开始怀疑是 CLAUDE.md 里记录的 CJK split-brain(HarfBuzz 塑形字体 ≠ FreeType 渲染字体)
在生产环境复发,理由是那套真正的修复 `fontRemapMiddleware` 是 **Vite dev-server 专属**,
生产静态部署不跑。并据此打算给 slide 编辑器补 Windows 路径字体 remap。

**用 chrome-devtools 实测后这个方向被推翻:**

1. 乱码串 `Ajgai rm_bb rgjc` 解码 = `Click to add title` 每个字母 glyph-ID **整体偏移 -2**
(C→A、l→j、i→g…),是 split-brain 特征,但这正是**旧构建**里"PPT 也做字体改写、
错映射到 glyph 不兼容字体"的行为。当前构建(`924ffb8` 改成仅 Excel 改写)不再这样。

2. 在线上反复冷加载(含 **20x CPU 降速 + Slow 3G** 极限放大竞态窗口)+ 暖加载共三轮,
**一次都没能复现乱码**,每次都是正常的 serif `Click to add title` / `Click to add subtitle`。

3. 三轮的字体网络请求**完全一致**:`c:\Windows\Fonts\arial.ttf`、`Deng.ttf`、`simsun.ttc`
等系统字体请求**每次都 `net::ERR_FAILED`**,且**没有任何 `/fonts/*.ttf` 被请求**——
说明 PPT 默认模板压根不用 `/fonts/` 下的 ttf,而是用 OnlyOffice 内置引擎(`fonts.wasm`)
渲染。那些失败请求在正常/乱码两种情况下都失败,**不是乱码原因**,remap 它们无意义。

## 真因

用户截图时 DevTools 正开在 **Application → Storage / Unregister service workers**,
结合"再刷一次就好"——真因是 **Service Worker 缓存住了旧构建**:

- `public/sw.js` 对非 HTML 静态资源用 stale-while-revalidate(先给缓存、后台再更新)。
- `bin/build.sh` 每次构建给 SW 注入时间戳 → 新 `CACHE_VERSION` → 新 `CACHE_NAME`,
`activate` 会清旧缓存。**版本化和清理机制都在**。
- 唯一缺陷是**"一次加载滞后"**:页面已用旧 SW / 旧缓存渲染完,新 SW 才 `skipWaiting` +
`clients.claim` 接管,但**不会重载已经加载好的页面**。于是用户必须再手刷一次才拿到新构建。
旧构建恰好含已修复前的 PPT 字体改写 bug → 首屏乱码,刷新后(新构建)正常。

## 修复

`index.ts` 的 SW 注册块新增 `controllerchange` 监听,新 SW 接管时**自动 reload 一次**,
带三道护栏:

```ts
const hadController = !!navigator.serviceWorker.controller;
let reloadingForUpdate = false;
navigator.serviceWorker.addEventListener('controllerchange', () => {
if (!hadController || reloadingForUpdate) return; // 1. 仅更新(非首装);2. 只 reload 一次
if (getDocmentObj().fileName) return; // 3. 有文档打开时不 reload,避免丢失编辑
reloadingForUpdate = true;
window.location.reload();
});
```

未改 `public/sw.js` 逻辑:新 SW 换 `CACHE_NAME` → SWR 在新(空)缓存里 miss → 从网络取新资源,
所以自动 reload 后即是最新构建,与旧缓存清理时序无关。

## 验证(chrome-devtools 端到端)

在 `vite preview`(dist)上:

| 场景 | 预期 | 实测 |
| --- | --- | --- |
| 首次安装(无旧控制器) | 不 reload | ✅ 载入计数停在 1 |
| 有旧控制器 + 未开文档(模拟新部署,合成 `controllerchange`) | 自动 reload 一次 | ✅ window marker 丢失、sessionStorage 保留 = 同页刷新 |
| 文档已打开时 `controllerchange` | 不 reload | ✅ marker 仍在、编辑器 iframe 完好 |

`pnpm run lint:ts` 通过;`sw-routing.test.ts` 31/31 通过;`pnpm run build` 成功。

## 结论

- **字体本身不用改**:当前构建 PPT 渲染正常,`924ffb8`(仅 Excel 改写)的修复是生效的。
- 用户看到的乱码是 **SW 缓存旧构建**,本次改动让新部署后**首访即自动切到新构建**,
消除"要再刷一次"的步骤,且不会打断正在编辑的文档。
25 changes: 25 additions & 0 deletions index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import { initEmbedApi } from './lib/embed-api';
import { initEvents, setEventUICallbacks } from './lib/events';
import { onCreateNew, openDocumentFromUrl, setUICallbacks } from './lib/document';
import { parseReadonly } from '@ranuts/shared/document-utils';
import { getDocmentObj } from '@ranuts/shared/store';
import { initAnalytics } from './lib/analytics';
import {
createControlPanel,
createFixedActionButton,
Expand All @@ -29,6 +31,9 @@ declare global {
initEvents();
initEmbedApi();

// Privacy-friendly analytics (no-op unless VITE_CF_BEACON_TOKEN is set; never in embed mode)
initAnalytics();

// Set up UI callbacks to avoid circular dependency
setUICallbacks({
hideControlPanel,
Expand Down Expand Up @@ -99,6 +104,26 @@ if (documentUrl) {

// Register Service Worker for PWA
if ('serviceWorker' in navigator) {
// Whether a SW was already controlling this page when it started. Each deploy
// rebuilds sw.js with a fresh CACHE_VERSION; the new SW skipWaiting()s and
// claims clients, firing `controllerchange`. If a SW was ALREADY in control at
// startup, that event means a *new build* took over — not the first install —
// so we can reload once to swap the stale assets for the fresh ones. Without
// this, the current page keeps rendering the previously-cached build until the
// user manually refreshes (the "refresh once more and it's fixed" symptom).
const hadController = !!navigator.serviceWorker.controller;
let reloadingForUpdate = false;

navigator.serviceWorker.addEventListener('controllerchange', () => {
// Guard 1: only on a real update (not first install). Guard 2: reload once.
// Guard 3: never while a document is open — a reload would discard unsaved
// edits. On the landing page fileName is empty, so the reload is invisible.
if (!hadController || reloadingForUpdate) return;
if (getDocmentObj().fileName) return;
reloadingForUpdate = true;
window.location.reload();
});

window.addEventListener('load', () => {
navigator.serviceWorker
.register('./sw.js')
Expand Down
49 changes: 49 additions & 0 deletions lib/analytics.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
/**
* Privacy-friendly page analytics via Cloudflare Web Analytics.
*
* Deliberately NOT Google Analytics: this project's whole pitch is local-only,
* no-server privacy, so we avoid Google's tracking scripts and cookies. The
* Cloudflare beacon is cookieless and needs no consent banner (GDPR-friendly).
*
* The beacon loads only when BOTH hold:
* 1. A token is configured via VITE_CF_BEACON_TOKEN. Unset -> disabled, zero
* external requests (so forks / local dev stay tracking-free by default).
* 2. The app is the top-level standalone page — never when embedded in a host
* site's iframe, which would attribute the host's visitors to us and leak
* analytics into pages that only wanted the editor.
*
* The token is a public client-side value (it ships in the page HTML by design),
* so injecting it at build time from an env var is safe.
*/

const BEACON_SRC = 'https://static.cloudflareinsights.com/beacon.min.js';

const EMBED_QUERY_KEYS = ['embed', 'embedded'];

/** Mirror of embed-api's detection: iframe-embedded or an ?embed=/?embedded= flag. */
function isEmbedded(): boolean {
if (window.parent !== window) {
return true;
}
const params = new URLSearchParams(window.location.search);
return EMBED_QUERY_KEYS.some((key) => {
const value = params.get(key);
return value === '' || value === '1' || value === 'true';
});
}

export function initAnalytics(): void {
const token = import.meta.env.VITE_CF_BEACON_TOKEN;
if (!token) {
return; // analytics disabled until a token is configured
}
if (isEmbedded()) {
return; // never track inside a host page's iframe
}

const script = document.createElement('script');
script.defer = true;
script.src = BEACON_SRC;
script.setAttribute('data-cf-beacon', JSON.stringify({ token }));
document.head.appendChild(script);
}
14 changes: 14 additions & 0 deletions vite-env.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
/// <reference types="vite/client" />

interface ImportMetaEnv {
/**
* Cloudflare Web Analytics beacon token. When unset, analytics is disabled
* and no external request is made — keeps forks and local dev tracking-free.
* The token is a public client-side value, so exposing it in the build is safe.
*/
readonly VITE_CF_BEACON_TOKEN?: string;
}

interface ImportMeta {
readonly env: ImportMetaEnv;
}
Loading