Skip to content

Commit 12e119a

Browse files
committed
chore: wip
1 parent 77ad00a commit 12e119a

File tree

5 files changed

+233
-2
lines changed

5 files changed

+233
-2
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,3 +42,4 @@ stacks.log
4242
.stx-serve
4343
.env.keys
4444
/storage/framework/frontend-dist
45+
/.qb

docs/config.ts

Lines changed: 51 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,12 +42,62 @@ export const fathomAnalyticsHead: HeadConfig[] = [
4242
],
4343
]
4444

45+
// Self-hosted analytics using Stacks Analytics (dynamodb-tooling)
46+
function generateSelfHostedAnalyticsScript(): string {
47+
const config = analytics.drivers?.selfHosted
48+
if (!config?.siteId || !config?.apiEndpoint) {
49+
return ''
50+
}
51+
52+
const honorDnt = config.honorDnt ? `if(n.doNotTrack==="1")return;` : ''
53+
const hashTracking = config.trackHashChanges ? `w.addEventListener('hashchange',pv);` : ''
54+
const outboundTracking = config.trackOutboundLinks
55+
? `d.addEventListener('click',function(e){var a=e.target.closest('a');if(a&&a.hostname!==location.hostname){t('outbound',{url:a.href});}});`
56+
: ''
57+
58+
return `(function(){
59+
'use strict';
60+
var d=document,w=window,n=navigator,s=d.currentScript;
61+
var site=s.dataset.site,api=s.dataset.api;
62+
${honorDnt}
63+
var q=[],sid=Math.random().toString(36).slice(2);
64+
function t(e,p){
65+
var x=new XMLHttpRequest();
66+
x.open('POST',api+'/collect',true);
67+
x.setRequestHeader('Content-Type','application/json');
68+
x.send(JSON.stringify({s:site,sid:sid,e:e,p:p||{},u:location.href,r:d.referrer,t:d.title,sw:screen.width,sh:screen.height}));
69+
}
70+
function pv(){t('pageview');}
71+
${hashTracking}
72+
${outboundTracking}
73+
if(d.readyState==='complete')pv();
74+
else w.addEventListener('load',pv);
75+
w.stacksAnalytics={track:function(n,v){t('event',{name:n,value:v});}};
76+
})();`
77+
}
78+
79+
export const selfHostedAnalyticsHead: HeadConfig[] = analytics.drivers?.selfHosted?.siteId
80+
? [
81+
[
82+
'script',
83+
{
84+
'data-site': analytics.drivers.selfHosted.siteId,
85+
'data-api': analytics.drivers.selfHosted.apiEndpoint || '',
86+
'defer': '',
87+
},
88+
generateSelfHostedAnalyticsScript(),
89+
],
90+
]
91+
: []
92+
4593
export const analyticsHead
4694
= analytics.driver === 'fathom'
4795
? fathomAnalyticsHead
4896
: analytics.driver === 'google-analytics'
4997
? googleAnalyticsHead
50-
: []
98+
: analytics.driver === 'self-hosted'
99+
? selfHostedAnalyticsHead
100+
: []
51101

52102
const nav = [
53103
{
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,2 @@
11
export * from './fathom'
2+
export * from './self-hosted'
Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
/**
2+
* Self-Hosted Analytics Driver
3+
*
4+
* This driver generates a minimal, privacy-focused tracking script
5+
* that sends analytics data to your own API endpoint (powered by dynamodb-tooling analytics).
6+
*
7+
* Features:
8+
* - No cookies required
9+
* - Privacy-focused (hashed visitor IDs)
10+
* - Do Not Track support
11+
* - Page view tracking
12+
* - Custom event tracking
13+
* - Outbound link tracking (optional)
14+
* - Hash-based routing support (optional)
15+
*/
16+
17+
export interface SelfHostedConfig {
18+
/** Site ID for tracking */
19+
siteId: string
20+
/** API endpoint URL for collecting analytics */
21+
apiEndpoint: string
22+
/** Honor Do Not Track browser setting */
23+
honorDnt?: boolean
24+
/** Track hash changes as page views */
25+
trackHashChanges?: boolean
26+
/** Track outbound link clicks */
27+
trackOutboundLinks?: boolean
28+
}
29+
30+
/**
31+
* Generate the self-hosted analytics tracking script
32+
*/
33+
export function generateSelfHostedScript(config: SelfHostedConfig): string {
34+
if (!config.siteId || !config.apiEndpoint) {
35+
return ''
36+
}
37+
38+
const siteId = escapeAttr(config.siteId)
39+
const apiEndpoint = escapeAttr(config.apiEndpoint)
40+
const honorDnt = config.honorDnt ? `if(n.doNotTrack==="1")return;` : ''
41+
const hashTracking = config.trackHashChanges ? `w.addEventListener('hashchange',pv);` : ''
42+
const outboundTracking = config.trackOutboundLinks
43+
? `
44+
d.addEventListener('click',function(e){
45+
var a=e.target.closest('a');
46+
if(a&&a.hostname!==location.hostname){
47+
t('outbound',{url:a.href});
48+
}
49+
});`
50+
: ''
51+
52+
return `<!-- Stacks Self-Hosted Analytics -->
53+
<script data-site="${siteId}" data-api="${apiEndpoint}" defer>
54+
(function(){
55+
'use strict';
56+
var d=document,w=window,n=navigator,s=d.currentScript;
57+
var site=s.dataset.site,api=s.dataset.api;
58+
${honorDnt}
59+
var q=[],sid=Math.random().toString(36).slice(2);
60+
function t(e,p){
61+
var x=new XMLHttpRequest();
62+
x.open('POST',api+'/collect',true);
63+
x.setRequestHeader('Content-Type','application/json');
64+
x.send(JSON.stringify({
65+
s:site,sid:sid,e:e,p:p||{},
66+
u:location.href,r:d.referrer,t:d.title,
67+
sw:screen.width,sh:screen.height
68+
}));
69+
}
70+
function pv(){t('pageview');}
71+
${hashTracking}
72+
${outboundTracking}
73+
if(d.readyState==='complete')pv();
74+
else w.addEventListener('load',pv);
75+
w.stacksAnalytics={track:function(n,v){t('event',{name:n,value:v});}};
76+
})();
77+
</script>`
78+
}
79+
80+
/**
81+
* Generate the head tag for including self-hosted analytics
82+
* Compatible with Stacks docs config.ts head array format
83+
*/
84+
export function getSelfHostedAnalyticsHead(config: SelfHostedConfig): [string, Record<string, string>][] {
85+
if (!config.siteId || !config.apiEndpoint) {
86+
return []
87+
}
88+
89+
// Return as an inline script tag configuration
90+
// Note: For VitePress/Stacks docs, inline scripts need special handling
91+
return [
92+
['script', {
93+
'data-site': config.siteId,
94+
'data-api': config.apiEndpoint,
95+
'defer': '',
96+
'innerHTML': generateInlineScript(config),
97+
}],
98+
]
99+
}
100+
101+
/**
102+
* Generate just the inline script content (without script tags)
103+
*/
104+
function generateInlineScript(config: SelfHostedConfig): string {
105+
const honorDnt = config.honorDnt ? `if(n.doNotTrack==="1")return;` : ''
106+
const hashTracking = config.trackHashChanges ? `w.addEventListener('hashchange',pv);` : ''
107+
const outboundTracking = config.trackOutboundLinks
108+
? `d.addEventListener('click',function(e){var a=e.target.closest('a');if(a&&a.hostname!==location.hostname){t('outbound',{url:a.href});}});`
109+
: ''
110+
111+
return `(function(){
112+
'use strict';
113+
var d=document,w=window,n=navigator,s=d.currentScript;
114+
var site=s.dataset.site,api=s.dataset.api;
115+
${honorDnt}
116+
var q=[],sid=Math.random().toString(36).slice(2);
117+
function t(e,p){
118+
var x=new XMLHttpRequest();
119+
x.open('POST',api+'/collect',true);
120+
x.setRequestHeader('Content-Type','application/json');
121+
x.send(JSON.stringify({s:site,sid:sid,e:e,p:p||{},u:location.href,r:d.referrer,t:d.title,sw:screen.width,sh:screen.height}));
122+
}
123+
function pv(){t('pageview');}
124+
${hashTracking}
125+
${outboundTracking}
126+
if(d.readyState==='complete')pv();
127+
else w.addEventListener('load',pv);
128+
w.stacksAnalytics={track:function(n,v){t('event',{name:n,value:v});}};
129+
})();`
130+
}
131+
132+
/**
133+
* Escape attribute value for safe HTML insertion
134+
*/
135+
function escapeAttr(str: string): string {
136+
return str
137+
.replace(/&/g, '&amp;')
138+
.replace(/"/g, '&quot;')
139+
.replace(/'/g, '&#39;')
140+
.replace(/</g, '&lt;')
141+
.replace(/>/g, '&gt;')
142+
}

storage/framework/core/types/src/analytics.ts

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,14 +6,51 @@
66
* have any questions, feel free to reach out via Discord or GitHub Discussions.
77
*/
88
export interface AnalyticsOptions {
9-
driver: 'google-analytics' | 'fathom'
9+
/** The analytics driver/provider to use */
10+
driver: 'google-analytics' | 'fathom' | 'plausible' | 'self-hosted'
1011

1112
drivers: {
13+
/** Google Analytics configuration */
1214
googleAnalytics?: {
15+
/** GA4 Measurement ID (e.g., G-XXXXXXXXXX) */
1316
trackingId: string
17+
/** Enable debug mode */
18+
debug?: boolean
1419
}
20+
/** Fathom Analytics configuration (privacy-focused) */
1521
fathom?: {
22+
/** Fathom site ID */
1623
siteId: string
24+
/** Custom script URL */
25+
scriptUrl?: string
26+
/** Honor Do Not Track browser setting */
27+
honorDnt?: boolean
28+
/** Enable SPA mode for client-side routing */
29+
spa?: boolean
30+
}
31+
/** Plausible Analytics configuration (privacy-focused, open source) */
32+
plausible?: {
33+
/** Your domain (e.g., example.com) */
34+
domain: string
35+
/** Custom script URL (for self-hosted Plausible) */
36+
scriptUrl?: string
37+
/** Track localhost during development */
38+
trackLocalhost?: boolean
39+
/** Enable hash-based routing */
40+
hashMode?: boolean
41+
}
42+
/** Self-hosted analytics configuration (using Stacks Analytics / dynamodb-tooling) */
43+
selfHosted?: {
44+
/** Site ID for tracking */
45+
siteId: string
46+
/** API endpoint URL for collecting analytics (e.g., https://api.example.com/analytics) */
47+
apiEndpoint: string
48+
/** Honor Do Not Track browser setting */
49+
honorDnt?: boolean
50+
/** Track hash changes as page views */
51+
trackHashChanges?: boolean
52+
/** Track outbound link clicks */
53+
trackOutboundLinks?: boolean
1754
}
1855
}
1956
}

0 commit comments

Comments
 (0)