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 apps/docs/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# PostHog (project "penciled", US Cloud). The token is public: it can only send events.
PUBLIC_POSTHOG_KEY=phc_p94VgqcYgGXsRoJHSzjvLsG4Gy9KMZHCR8LYUoqj4Apm
# /ingest is proxied to https://us.i.posthog.com by vercel.json (and by the dev server), so ad
# blockers don't drop events. Set https://us.i.posthog.com to send events directly.
PUBLIC_POSTHOG_HOST=/ingest
33 changes: 33 additions & 0 deletions apps/docs/ANALYTICS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
# Site analytics

penciled.org (the landing page and the docs) reports to PostHog: project **penciled** (ID 697762), US Cloud. The setup lives in [`src/scripts/analytics.ts`](./src/scripts/analytics.ts), loaded by the landing page and by Starlight's `Head` override.

- **Automatic events:** `$pageview` (also on client-side history changes), `$pageleave` and `$autocapture`. Session recording is off.
- **Super property:** every event carries `product: "penciled"`.
- **Persons:** anonymous visitors don't create person profiles (`person_profiles: 'identified_only'`).
- **Proxy:** events go to `/ingest`, which `vercel.json` rewrites to `us.i.posthog.com` (and `/ingest/static` to `us-assets.i.posthog.com`), so ad blockers don't drop them. `astro dev` proxies `/ingest` the same way.

## Configuration

| Variable | Default | |
| --- | --- | --- |
| `PUBLIC_POSTHOG_KEY` | the penciled project token | Public: it can only send events. |
| `PUBLIC_POSTHOG_HOST` | `/ingest` | Set `https://us.i.posthog.com` to skip the proxy. |

See [`.env.example`](./.env.example). Neither variable is required.

## Custom events

Every click event has `location` (`header`, `hero`, `footer`, `landing` or `docs`) and `path`.

| Event | When | Extra properties |
| --- | --- | --- |
| `extension_install_clicked` | A link to the Chrome Web Store, Firefox Add-ons or Edge Add-ons | `browser`: `chrome`, `firefox` or `edge` |
| `github_clicked` | A link to the GitHub repo (header, footer, edit links, Markdown) | `url` |
| `npm_clicked` | A link to the npm package | |
| `install_command_copied` | The landing page's copy button, or copying a docs code block that installs penciled (`npm`/`pnpm`/`yarn`/`bun`) | `command`: the first line copied |
| `docs_cta_clicked` | "Read the docs" (hero) or "Get started" (end of the landing page) | |
| `stackblitz_opened` | The "Edit on StackBlitz" link | |
| `demo_highlight_created` | The visitor's first highlight in the landing page demo, once per page view (the seeded highlights don't count) | `color` |

Link events are classified by destination in one delegated listener, so new links to the stores, GitHub or npm are tracked without changes. To track another internal link, add `data-ph-event="<event_name>"` to it.
17 changes: 17 additions & 0 deletions apps/docs/astro.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,23 @@ const ogImage = new URL('/og.png', site).href;

export default defineConfig({
site,
// In production, vercel.json proxies /ingest to PostHog; this does the same for `astro dev`.
vite: {
server: {
proxy: {
'/ingest/static': {
target: 'https://us-assets.i.posthog.com',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/ingest/, ''),
},
'/ingest': {
target: 'https://us.i.posthog.com',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/ingest/, ''),
},
},
},
},
integrations: [
starlight({
title: 'penciled',
Expand Down
2 changes: 1 addition & 1 deletion apps/docs/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,9 @@
"@fontsource-variable/geist": "^5.3.0",
"@fontsource-variable/geist-mono": "^5.3.0",
"@fontsource-variable/source-serif-4": "^5.3.1",
"@vercel/analytics": "^2.0.1",
"astro": "^7.4.0",
"penciled": "workspace:*",
"posthog-js": "^1.442.1",
"sharp": "^0.35.5",
"starlight-llms-txt": "^0.12.0"
},
Expand Down
8 changes: 4 additions & 4 deletions apps/docs/src/components/Head.astro
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
---
// Starlight's head with descriptive titles and structured data, plus Vercel Web Analytics
// (cookieless, so no consent banner).
// Starlight's head with descriptive titles and structured data, plus PostHog analytics.
import Default from '@astrojs/starlight/components/Head.astro';
import Analytics from '@vercel/analytics/astro';
import { disambiguation, faq } from '../faq.ts';

const route = Astro.locals.starlightRoute;
Expand Down Expand Up @@ -82,4 +80,6 @@ const structuredData = url &&

<Default />
{structuredData && <script type="application/ld+json" set:html={JSON.stringify(structuredData)} />}
<Analytics />
<script>
import '../scripts/analytics.ts';
</script>
7 changes: 3 additions & 4 deletions apps/docs/src/pages/index.astro
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ import '../styles/tokens.css';
import '../styles/custom.css';
import '../styles/landing.css';
import { Code } from '@astrojs/starlight/components';
import Analytics from '@vercel/analytics/astro';
import { disambiguation, homeQuestion, links } from '../faq.ts';
import { scoped, skins } from '../skins.ts';

Expand Down Expand Up @@ -195,7 +194,6 @@ export function Post({ post }) {
document.documentElement.dataset.theme = theme;
})();
</script>
<Analytics />
</head>
<body>
<header class="top">
Expand Down Expand Up @@ -249,7 +247,7 @@ export function Post({ post }) {
>
</button>
</div>
<a class="primary" href="/getting-started/">Read the docs <span aria-hidden="true">→</span></a>
<a class="primary" href="/getting-started/" data-ph-event="docs_cta_clicked">Read the docs <span aria-hidden="true">→</span></a>
</section>
</div>
<p class="support">
Expand Down Expand Up @@ -397,7 +395,7 @@ export function Post({ post }) {
</section>

<nav class="next" aria-label="Next steps">
<a href="/getting-started/">Get started <span aria-hidden="true">→</span></a>
<a href="/getting-started/" data-ph-event="docs_cta_clicked">Get started <span aria-hidden="true">→</span></a>
<a href="/playground/">Playground</a>
<a href="/reference/options/">API reference</a>
</nav>
Expand All @@ -413,6 +411,7 @@ export function Post({ post }) {
</footer>

<script>
import '../scripts/analytics.ts';
import '../scripts/landing.ts';
</script>
</body>
Expand Down
70 changes: 70 additions & 0 deletions apps/docs/src/scripts/analytics.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
// PostHog product analytics for the site: pageviews, pageleaves, autocapture and a few named
// events for the main calls to action (listed in apps/docs/ANALYTICS.md). Events go to /ingest,
// which vercel.json (and the dev server) proxies to PostHog, so ad blockers don't drop them.
import posthog from 'posthog-js';

// The project token is public: it can only send events, not read them.
const key =
import.meta.env.PUBLIC_POSTHOG_KEY || 'phc_p94VgqcYgGXsRoJHSzjvLsG4Gy9KMZHCR8LYUoqj4Apm';
const host = import.meta.env.PUBLIC_POSTHOG_HOST || '/ingest';

posthog.init(key, {
api_host: host,
ui_host: 'https://us.posthog.com',
// The site is a multi-page app, but history_change also covers any client-side navigation.
capture_pageview: 'history_change',
capture_pageleave: true,
autocapture: true,
disable_session_recording: true,
person_profiles: 'identified_only',
});
posthog.register({ product: 'penciled' });

/** Where on the site a click happened, so the same CTA can be compared across places. */
function placement(el: Element): string {
if (el.closest('header')) return 'header';
if (el.closest('footer')) return 'footer';
if (el.closest('.band')) return 'hero';
return window.location.pathname === '/' ? 'landing' : 'docs';
}

const STORES: Record<string, string> = {
'chromewebstore.google.com': 'chrome',
'addons.mozilla.org': 'firefox',
'microsoftedge.microsoft.com': 'edge',
};

// Outbound links, classified by where they go. One delegated listener covers the landing page and
// every docs page, including links written in Markdown.
document.addEventListener(
'click',
(event) => {
const target = event.target as Element | null;
const link = target?.closest<HTMLAnchorElement>('a[href]');
if (link) {
const url = new URL(link.href, window.location.href);
const props = { location: placement(link), path: window.location.pathname };
const browser = STORES[url.hostname];
if (browser) posthog.capture('extension_install_clicked', { ...props, browser });
else if (url.hostname === 'github.com' && url.pathname.startsWith('/marcellosso/penciled')) {
posthog.capture('github_clicked', { ...props, url: url.href });
} else if (url.hostname === 'www.npmjs.com') posthog.capture('npm_clicked', props);
else if (url.hostname === 'stackblitz.com') posthog.capture('stackblitz_opened', props);
else if (link.dataset.phEvent) posthog.capture(link.dataset.phEvent, props);
return;
}

// Copying an install command: the landing page's button, or a code block's copy button in the
// docs (Expressive Code keeps the code in data-code, with U+007F for line breaks).
const copy = target?.closest<HTMLElement>('#copy-install, [data-code]');
const code = copy?.id === 'copy-install' ? 'npm install penciled' : copy?.dataset.code;
if (copy && code && /\b(npm|pnpm|yarn|bun)\b[^\n\x7f]*\bpenciled\b/.test(code)) {
posthog.capture('install_command_copied', {
location: placement(copy),
path: window.location.pathname,
command: code.split(/[\n\x7f]/)[0],
});
}
},
{ capture: true },
);
13 changes: 13 additions & 0 deletions apps/docs/src/scripts/landing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
type Annotator,
annotate,
} from 'penciled';
import posthog from 'posthog-js';

const reduced = matchMedia('(prefers-reduced-motion: reduce)').matches;

Expand Down Expand Up @@ -34,11 +35,15 @@ function find(container: Element, text: string): Range | undefined {

type Seed = AnnotationInit & { text: string };

let seeding = false;

function seed(instance: Annotator, seeds: Seed[]) {
seeding = true;
for (const { text, ...init } of seeds) {
const range = find(instance.container, text);
if (range) instance.create(range, init);
}
seeding = false;
}

/** An example that starts from `seeds` on every visit. */
Expand Down Expand Up @@ -104,6 +109,14 @@ const count = () => {
};
for (const event of ['load', 'create', 'delete'] as const) demo.on(event, count);

// The visitor's first highlight in the demo, once per page view: the site's activation moment.
let tried = false;
demo.on('create', (annotation) => {
if (seeding || tried) return;
tried = true;
posthog.capture('demo_highlight_created', { color: annotation.color });
});

// Colors.
example(
'#ex-colors',
Expand Down
9 changes: 8 additions & 1 deletion apps/docs/vercel.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,12 @@
"framework": "astro",
"buildCommand": "cd ../.. && pnpm docs:build",
"outputDirectory": "dist",
"rewrites": [{ "source": "/sitemap.xml", "destination": "/sitemap-index.xml" }]
"rewrites": [
{ "source": "/sitemap.xml", "destination": "/sitemap-index.xml" },
{
"source": "/ingest/static/:path*",
"destination": "https://us-assets.i.posthog.com/static/:path*"
},
{ "source": "/ingest/:path*", "destination": "https://us.i.posthog.com/:path*" }
]
}
Loading
Loading