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
20 changes: 20 additions & 0 deletions .github/workflows/i18n-check.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,12 @@ on:
paths:
- 'docs/pages/**'
- 'docs/sidebar.json'
- 'docs/lib/structured-data.ts'
- 'docs/lib/verify-i18n.mjs'
- 'docs/lib/verify-structured-data.mts'
- 'docs/i18n-allowlist.json'
- 'package.json'
- 'package-lock.json'
workflow_dispatch:

jobs:
Expand All @@ -24,9 +28,25 @@ jobs:
node-version-file: '.nvmrc'
cache: npm
- run: npm ci
- name: Detect changed en pages
id: changed-en
if: github.event_name == 'pull_request'
run: |
BASE="${{ github.event.pull_request.base.sha }}"
if git diff --name-only "$BASE"...HEAD -- 'docs/pages/en' | grep -q '\.mdx$'; then
echo 'changed=true' >> "$GITHUB_OUTPUT"
else
echo 'changed=false' >> "$GITHUB_OUTPUT"
fi
# Blocks the PR when any en page is missing a translation.
- name: Check translation parity
run: npm run i18n:check
# When English source changed, translations must be fresh before merge.
- name: Check changed English translations strictly
if: steps.changed-en.outputs.changed == 'true'
run: npm run i18n:check:strict
- name: Check structured data
run: npm run structured-data:check
# Catches broken internal links and missing sidebar targets in every locale.
- name: Build docs
run: npm run docs:build
117 changes: 117 additions & 0 deletions ADRs/DR006_PostHog_Analytics.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
# DR006: PostHog Analytics Integration

> **Amended by [DR007](./DR007_Analytics_Consent.md):** the `posthog.init` config
> and the "`Layout` slot unused" / "not via `Layout`" framing below predate the
> consent gate. PostHog now initialises opted-out and cookieless until the visitor
> consents, and the `Layout`/`footer.tsx` slots carry the consent UI. The `head`
> snippet mechanism for the PostHog **loader** (the decision recorded here) is
> unchanged.

## Context

The previous Mintlify docs site reported into PostHog via Mintlify's built-in
integration — a single key in `dev-docs/docs.json`:

```json
"posthog": { "apiKey": "phc_w5S82EA6htCdGahiKPNEskpaEr9PofM5YDKsw8JtfhUi" }
```

The migration to Vocs dropped this; the new site sent **no analytics** and
`vocs.config.ts` carried only a "wire PostHog later" TODO. We needed to restore
reliable event flow into the **same** PostHog project so historical data stays
continuous across the migration.

Two constraints shaped the decision:

1. **Same project, no break in continuity.** Reuse the existing project key
(`phc_…`, public/write-only — safe to ship to the browser) and PostHog's US
ingestion host (`https://us.i.posthog.com`), which is the host Mintlify's
integration defaulted to.
2. **Vocs is a client-side-routed SPA.** A plain analytics snippet captures the
initial page load only; in-app navigations between docs pages would be missed.

Vocs (on **v1.4.1** at the time of this decision) has **no dedicated analytics
feature**, and the hosted docs have no analytics guide (`vocs.dev/docs/guides/
analytics` 404s). The framework offers two documented extension points, confirmed
from the installed type defs / source rather than the website:

- **`head` config option** (`node_modules/vocs/_lib/config.d.ts`: *"Additional
tags to include in the `<head>` tag of the page HTML"*) — a `ReactElement`,
path-map, or `(params) => ReactElement` rendered into `<head>` at static-build
time. Already used in this repo for JSON-LD/SEO (see
[DR001](./DR001_SEO_Structured_Data.md)).
- **`layout.tsx` consumer components** — Vocs reads `rootDir/layout.tsx` for a
default `Layout` export (wraps the whole app, client-side) plus named exports
like `TopNavEnd` (`node_modules/vocs/_lib/vite/plugins/virtual-consumer-
components.js`). The repo already uses `TopNavEnd`; the default `Layout` slot is
unused.

## Decision

Inject the **standard PostHog browser snippet** via the Vocs **`head` option** —
*not* via `posthog-js` in a `Layout` component.

The snippet lives in its own module, **`docs/lib/analytics.ts`**, exporting
`analyticsHead(): ReactElement` (a `<script>` with the PostHog loader +
`posthog.init`). `vocs.config.ts` composes it with the existing SEO `head()` into
a single `Fragment`, since Vocs' `head` takes one value:

```ts
head: (params) =>
createElement(Fragment, null, analyticsHead(), seoHead(params)),
```

**`posthog.init` config that matters:**

- `api_host: 'https://us.i.posthog.com'` + the migrated `phc_…` key — same
project as Mintlify, so data is continuous.
- `capture_pageview: 'history_change'` — fires `$pageview` on
`pushState`/`popstate`, which is what makes SPA navigation tracking reliable.
Without it only the initial load would be counted.
- `person_profiles: 'identified_only'` — docs traffic is anonymous; don't create
a person profile per visitor.

**Why the `head` snippet over `posthog-js` in `Layout`:**

| | `head` snippet (chosen) | `Layout` + `posthog-js` |
| --- | --- | --- |
| Reliability | Loads async, independent of the app bundle — keeps reporting even if React fails to hydrate. Mirrors what Mintlify did. | Tied to bundle hydration. |
| Dependencies | None | Adds `posthog-js` (+ provider) to the build. |
| SPA pageviews | `capture_pageview: 'history_change'` | React effect / provider |
| Repo fit | Reuses the existing `head` injection pattern (DR001). | Would activate the unused `Layout` slot. |

Reliability of event flow was the priority, so the dependency-free, bundle-
independent snippet won.

**Why a separate module, not `structured-data.ts`:** that file is intentionally
single-purpose (SEO/JSON-LD per DR001). Keeping analytics in `analytics.ts` and
composing in config keeps each concern self-documenting and the snippet trivial to
find or remove.

## Consequences

- All built pages (~379 HTML files in `docs/dist`) carry the PostHog snippet, and
the SEO JSON-LD `head` from DR001 is unaffected — the two are composed, not
competing.
- The public key is committed in `docs/lib/analytics.ts`. This is by design for a
client-side analytics key; rotating the PostHog project means editing the
`POSTHOG_KEY`/`POSTHOG_HOST` constants there.
- The snippet string is the verbatim PostHog loader. To re-sync it with a newer
PostHog snippet, replace the `SNIPPET` body but keep the `posthog.init` options
above.
- **Node ≥ 22 is required to build** (`package.json` engines / `.nvmrc`). On older
Node the Vocs build fails with an unrelated `globSync` import error — not an
analytics issue.

### Verification

1. **Build:** `npm run docs:build` (Node ≥ 22).
2. **Snippet present on every page** (zsh-safe — avoid `--include`):
```bash
echo "with key: $(grep -rl 'phc_w5S82EA6' docs/dist | wc -l)"
echo "html total: $(find docs/dist -name '*.html' | wc -l)" # should match
```
3. **SPA config present:** `grep -o "capture_pageview:'history_change'" docs/dist/index.html`
4. **Live ingestion** (not verifiable from a static build): run `npm run docs:dev`
or check the deployed site, then watch PostHog's *Activity / live events*, or
the Network tab for requests to `us.i.posthog.com`.
105 changes: 105 additions & 0 deletions ADRs/DR007_Analytics_Consent.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
# DR007: Analytics Consent Gate

Amends [DR006](./DR006_PostHog_Analytics.md). DR006 restored PostHog event flow;
this record adds the consent gate around it. DR006's core mechanism (the PostHog
loader injected via Vocs' `head` option) stands unchanged — this layers opt-in
consent on top and, in doing so, activates the `Layout`/`footer.tsx` consumer
slots DR006 described as unused.

## Context

As shipped in DR006, PostHog captured `$pageview` on every navigation, ran
autocapture, and set its cookies on first load — unconditionally. That is product
analytics, **not** strictly-necessary error telemetry, so under GDPR / ePrivacy
(and equivalents — UK PECR, etc.) it requires **prior, opt-in consent**: nothing
non-essential may be captured or stored until the visitor actively agrees, reject
must be as easy as accept, and consent must be withdrawable as easily as it was
given. The DR006 build met none of these.

Two ways to comply:

1. **Geo-gate** — only prompt EU/EEA/UK visitors (needs edge/server geo-detection),
or run PostHog fully cookieless everywhere.
2. **Consent gate everywhere** — initialise opted-out and cookieless, capture
nothing until the visitor chooses, remember the choice.

## Decision

Take the **consent gate, applied globally** (not geo-gated), so the rule holds in
every region and needs no request-time geo lookup (the docs are statically
prerendered — see DR006).

**PostHog inits suppressed** (`docs/lib/analytics.ts`):

```ts
posthog.init(KEY, {
/* …DR006 options… */
opt_out_capturing_by_default: true, // capture nothing until opt-in
persistence: 'memory', // …and set no cookies until opt-in
})
```

`persistence: 'memory'` means the *only* thing stored before consent is the
visitor's own choice, under our own `localStorage` key `stable-analytics-consent`
(`CONSENT_KEY`) — a strictly-necessary value, exempt from consent. On opt-in we
switch to `persistence: 'localStorage+cookie'` and call `opt_in_capturing()`; on
opt-out, `opt_out_capturing()`.

**Honour a prior choice at load.** An inline bootstrap appended to the `head`
snippet reads `CONSENT_KEY` and, if `'granted'`, opts in *before React hydrates* —
so returning consenters are tracked immediately, not one render late. Wrapped in
`try/catch` so blocked storage can never break page load. (The PostHog stub queues
these calls until `array.js` loads, so calling them early is safe.)

**Prompt + withdraw — via the consumer slots DR006 left unused:**

- **`ConsentBanner`** (`docs/components/ConsentBanner.tsx`) mounts site-wide
through the **default `Layout` export** in `docs/layout.tsx` (a pass-through
wrapper Vocs renders around every page). It shows only when no choice is stored,
with **equal-weight Accept / Decline** — neither hidden nor de-emphasised.
- A **"Cookie preferences"** control in the **`footer.tsx`** consumer slot
re-opens the banner (via an `OPEN_CONSENT_EVENT` window event) so consent can be
changed or withdrawn as easily as it was granted.

Consent state and the PostHog calls live in `analytics.ts`
(`getConsent` / `grantConsent` / `denyConsent`); the React components only render
and dispatch. Keeps the single analytics seam authoritative (cf. DR006's
"separate module" rationale).

**Why this revises DR006.** DR006 chose the `head` snippet *over* `posthog-js` in
a `Layout` component, and noted the `Layout` slot was unused. That trade-off was
about how the **PostHog loader** ships, and is unchanged — the loader is still the
dependency-free `head` snippet. The consent **UI** is a separate concern and is
the natural use for the `Layout`/`footer` slots; no `posthog-js` dependency is
added (the components drive `window.posthog` directly).

## Consequences

- **Default state is no tracking.** A first-time or declining visitor produces no
PostHog events and no PostHog cookies. Expect EU analytics volume to drop to
consenting visitors only — this is the intended, compliant behaviour, not a
regression.
- **DR006's "snippet on every page" check no longer implies active tracking.** The
snippet is present everywhere, but capture is gated. To verify tracking, accept
the banner and watch for `us.i.posthog.com` requests (see Verification).
- **Cross-property consent is not shared.** The choice is stored per-origin in the
docs' own `localStorage`, so a visitor who accepted on another `stable.xyz`
property is still asked here. Unifying consent across `*.stable.xyz` would need a
shared parent-domain cookie + an agreed mechanism across hub/faucet/landing;
deferred until that pattern is settled.
- **Consent copy/styling is docs-local** and should be reconciled with the other
properties' banners once they land, for a consistent UX.
- Rotating or removing analytics still happens in `analytics.ts` (DR006); the
consent helpers and `CONSENT_KEY` live alongside the snippet there.

### Verification

1. **Build:** `npm run docs:build` (Node ≥ 22, per DR006).
2. **Opted out by default:** load a built page fresh (no `stable-analytics-consent`
in `localStorage`) → banner shows, and no request goes to `us.i.posthog.com`.
3. **Init is gated:** `grep -o "opt_out_capturing_by_default:true" docs/dist/index.html`
and `grep -o "persistence:'memory'" docs/dist/index.html`.
4. **Accept → tracking on:** click Accept → `$pageview` requests appear to
`us.i.posthog.com`; reload navigates are captured; the choice persists.
5. **Withdraw:** "Cookie preferences" in the footer re-opens the banner; Decline
stops capture.
4 changes: 3 additions & 1 deletion ADRs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,4 +31,6 @@ Each ADR should follow this general structure:
- [DR002: i18n Parity & Translation Pipeline](./DR002_i18n_Sync_Pipeline.md) — en as source of truth; checker + CI gate + auto-draft translation engine to keep cn/ko in sync
- [DR003: Page filenames must not end in `index`](./DR003_Page_Filename_Index_Constraint.md) — Vocs strips a trailing `index` from any filename; `*-index.mdx` pages 404. Use `index.mdx` or a non-`index` suffix.
- [DR004: Translation LLM provider](./DR004_Translation_LLM_Provider.md) — swappable OpenAI-compatible seam (`llm.mjs`) defaulting to OpenRouter + a cheap model, optional review pass, structural + link guards; supersedes DR002 §5–6 internals.
- [DR005: Styleguide enforcement](./DR005_Styleguide_Enforcement.md) — mechanical rules in a single `RULES` source enforced by `verify-style.mjs`, surfaced on PRs as a sticky comment + inline applyable suggestions; judgment rules stay prose.
- [DR005: Styleguide enforcement](./DR005_Styleguide_Enforcement.md) — mechanical rules in a single `RULES` source enforced by `verify-style.mjs`, surfaced on PRs as a sticky comment + inline applyable suggestions; judgment rules stay prose.
- [DR006: PostHog Analytics Integration](./DR006_PostHog_Analytics.md) — restore Mintlify-era PostHog via the Vocs `head` option (snippet, not posthog-js); SPA pageviews via `capture_pageview: 'history_change'`
- [DR007: Analytics Consent Gate](./DR007_Analytics_Consent.md) — opt-in consent for the DR006 PostHog integration (GDPR/ePrivacy); inits opted-out + cookieless, site-wide banner via the `Layout` slot, withdraw via a footer link
32 changes: 31 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,14 @@
Guidance for AI agents working in this repo. Also read [`CONTRIBUTING.md`](./CONTRIBUTING.md)
and, before architectural changes, search [`ADRs/`](./ADRs).

## Before editing

- Run under Node 22 (`nvm use` from the repo root).
- Edit source content in `docs/pages/en/` only.
- If pages are added, moved, renamed, or deleted, update only the `/en` section
of `docs/sidebar.json`.
- Run `npm run check` before finishing.

## i18n: English is the source of truth

Content lives in **`docs/pages/en/`** only. The `cn` (Chinese) and `ko` (Korean)
Expand Down Expand Up @@ -39,7 +47,7 @@ the Diátaxis structure: `explanation/`, `how-to/`, `reference/`, `tutorial/`,
enables a second QA pass; `MAX_OUTPUT_TOKENS` (default 8000) must fit the chosen
model's output cap. Changing provider or model is config, never a code edit.
- **Verify** before finishing: `npm run i18n:check` (expect `0 missing`) and
`npm run docs:build`.
`npm run check`.

Full rationale: [`ADRs/DR002_i18n_Sync_Pipeline.md`](./ADRs/DR002_i18n_Sync_Pipeline.md).

Expand All @@ -54,3 +62,25 @@ mechanical rules are enforced on every PR by `npm run style:check`
## Build

Node `>=22`. `npm run docs:dev` / `docs:build` / `docs:preview`.

Use `npm run check` for the full local gate. It runs the Node preflight, style
check, i18n parity check, TypeScript check, structured-data check, and docs build.

## Common mistakes

- Do not hand-edit `docs/pages/cn/` or `docs/pages/ko/`.
- Do not hand-edit localized sidebar sections; edit only `/en` in
`docs/sidebar.json`.
- Do not treat green `npm run i18n:check` as proof translations are fresh;
stale translations are advisory unless `npm run i18n:check:strict` runs.
- Search ADRs before changing i18n, SEO, analytics, consent, style enforcement,
or Vocs runtime behavior.

## Repo Map

- **Content:** `docs/pages/en/`
- **Generated translations:** `docs/pages/cn/`, `docs/pages/ko/`
- **Navigation:** `docs/sidebar.json`
- **Runtime customization:** `vocs.config.ts`, `docs/layout.tsx`, `docs/styles.css`
- **SEO and analytics:** `docs/lib/structured-data.ts`, `docs/lib/analytics.ts`
- **Automation:** `docs/lib/*.mjs`, `.github/workflows/*`
28 changes: 23 additions & 5 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,14 @@ auto-generated translation, you're off-process.

> Why this exists (the full rationale): [`ADRs/DR002_i18n_Sync_Pipeline.md`](./ADRs/DR002_i18n_Sync_Pipeline.md).

## Before editing

- Run `nvm use` so Node 22 is active.
- Edit source content in `docs/pages/en/` only.
- If pages are added, moved, renamed, or deleted, update only the `/en` section
of `docs/sidebar.json`.
- Run `npm run check` before opening or handing off a PR.

## Writing or updating a page

1. **Edit English only.** New page → create it under the right Diátaxis folder in
Expand Down Expand Up @@ -44,15 +52,16 @@ auto-generated translation, you're off-process.
- **Fork PRs:** the bot can't push to a fork, so translations won't auto-commit and
`i18n-check` will fail. A maintainer runs the scripts locally and pushes:
```bash
ANTHROPIC_API_KEY=… node docs/lib/i18n-translate.mjs cn <changed/page.mdx> …
ANTHROPIC_API_KEY=… node docs/lib/i18n-translate.mjs ko <changed/page.mdx> …
LLM_API_KEY=… node docs/lib/i18n-translate.mjs cn <changed/page.mdx> …
LLM_API_KEY=… node docs/lib/i18n-translate.mjs ko <changed/page.mdx> …
# if the /en sidebar changed:
ANTHROPIC_API_KEY=… node docs/lib/i18n-sidebar.mjs cn
ANTHROPIC_API_KEY=… node docs/lib/i18n-sidebar.mjs ko
LLM_API_KEY=… node docs/lib/i18n-sidebar.mjs cn
LLM_API_KEY=… node docs/lib/i18n-sidebar.mjs ko
```
- **Local checks:**
```bash
npm run i18n:check # parity + freshness snapshot (0 missing = structurally complete)
npm run check # full local gate
npm run docs:build # builds en/cn/ko, catches broken links
npm run docs:dev # local preview
```
Expand All @@ -63,7 +72,7 @@ auto-generated translation, you're off-process.
| --- | --- |
| `docs/pages/en/**` | Canonical content (Diátaxis structure) |
| `docs/lib/verify-i18n.mjs` (`npm run i18n:check`) | Parity gate (block on missing) + staleness (warn on drifted `source_sha`) |
| `docs/lib/i18n-translate.mjs <cn\|ko> [--stale] [--relink] [pages…]` | Page translation engine (`claude-opus-4-8`); stamps `source_path`/`source_sha`. `--relink` = no-API link-prefix backfill |
| `docs/lib/i18n-translate.mjs <cn\|ko> [--stale] [--relink] [pages…]` | Page translation engine (`google/gemini-2.5-flash` by default via `docs/lib/llm.mjs`); stamps `source_path`/`source_sha`. `--relink` = no-API link-prefix backfill |
| `docs/lib/i18n-sidebar.mjs <cn\|ko>` | Regenerates the `/cn`+`/ko` sidebar sections from `/en` (localized links + labels) |
| `.github/workflows/i18n-check.yml` | Runs the gate + build on every PR |
| `.github/workflows/i18n-translate.yml` | Translates a PR's changed en pages + regenerates localized sidebars, in-PR |
Expand All @@ -72,3 +81,12 @@ auto-generated translation, you're off-process.
A translation is **fresh** when its frontmatter `source_sha` equals
`git hash-object` of the current en file at the same path; editing the English page
flips it to stale until re-translated.

## Repo Map

- **Content:** `docs/pages/en/`
- **Generated translations:** `docs/pages/cn/`, `docs/pages/ko/`
- **Navigation:** `docs/sidebar.json`
- **Runtime customization:** `vocs.config.ts`, `docs/layout.tsx`, `docs/styles.css`
- **SEO and analytics:** `docs/lib/structured-data.ts`, `docs/lib/analytics.ts`
- **Automation:** `docs/lib/*.mjs`, `.github/workflows/*`
Loading
Loading