@strifeapp/astro 1.3.0 → 1.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +24 -0
- package/dist/Insights.astro +247 -0
- package/package.json +5 -2
package/README.md
CHANGED
|
@@ -10,6 +10,7 @@ Official [Strife](https://strife.app) integration for [Astro](https://astro.buil
|
|
|
10
10
|
- [Requirements](#requirements)
|
|
11
11
|
- [Usage](#usage)
|
|
12
12
|
- [Reading content](#reading-content)
|
|
13
|
+
- [Telemetry: `<Insights />`](#telemetry-insights)
|
|
13
14
|
- [TypeScript: typing `strife:store`](#typescript-typing-strifestore)
|
|
14
15
|
- [Direct Vite plugin](#direct-vite-plugin)
|
|
15
16
|
- [Configuration](#configuration)
|
|
@@ -76,6 +77,29 @@ const page = await session
|
|
|
76
77
|
---
|
|
77
78
|
```
|
|
78
79
|
|
|
80
|
+
### Telemetry: `<Insights />`
|
|
81
|
+
|
|
82
|
+
Drop-in Web Vitals + accessibility telemetry. Add it once to your layout `<head>`, the same way you'd add Astro's `<ClientRouter />`:
|
|
83
|
+
|
|
84
|
+
```astro
|
|
85
|
+
---
|
|
86
|
+
import Insights from '@strifeapp/astro/Insights.astro';
|
|
87
|
+
---
|
|
88
|
+
<head>
|
|
89
|
+
…
|
|
90
|
+
<Insights />
|
|
91
|
+
</head>
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
On every page load it reports Core Web Vitals (LCP, INP, CLS, FCP, TTFB) and, when the browser is idle, an [axe-core](https://github.com/dequelabs/axe-core) accessibility audit (re-running on Astro View Transitions). Both are sent with `navigator.sendBeacon` to the hosted Strife insights API and stored as RavenDB time series on the page's content document. The beacon carries your **public team id** — resolved server-side from your Strife env, so no secret reaches the browser — letting the API attribute metrics to your workspace and validate the request against your registered domains.
|
|
95
|
+
|
|
96
|
+
| Prop | Type | Default | Description |
|
|
97
|
+
| --- | --- | --- | --- |
|
|
98
|
+
| `endpoint` | `string` | `https://api.strife.app/insights/collect` | Override the collection endpoint. |
|
|
99
|
+
| `accessibility` | `boolean` | `true` | Set `false` to skip the axe-core audit (Web Vitals still reported). |
|
|
100
|
+
|
|
101
|
+
> The API only stores a beacon when its `Origin` is one of the team's registered domains. For local development, run the API in `Development` and add `localhost` to your team's `Domains`.
|
|
102
|
+
|
|
79
103
|
### TypeScript: typing `strife:store`
|
|
80
104
|
|
|
81
105
|
Typed ambient declarations for the `strife:store` virtual module are not bundled in this release. Until a typed surface is published, add your own declaration (e.g. in `src/env.d.ts`):
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* Strife Insights — drop-in Web Vitals + accessibility telemetry.
|
|
4
|
+
*
|
|
5
|
+
* Add once to your layout <head>, the same way you'd add Astro's <ClientRouter />:
|
|
6
|
+
*
|
|
7
|
+
* ---
|
|
8
|
+
* import Insights from '@strifeapp/astro/Insights.astro';
|
|
9
|
+
* ---
|
|
10
|
+
* <head>…<Insights /></head>
|
|
11
|
+
*
|
|
12
|
+
* Real visitors report Core Web Vitals (LCP, INP, CLS, FCP, TTFB), batched into a
|
|
13
|
+
* single beacon when the page is backgrounded. The heavier axe-core accessibility
|
|
14
|
+
* audit runs ONLY inside the Strife live preview (edit mode), so axe-core is never
|
|
15
|
+
* downloaded or run by a real visitor. Both are beaconed to the central Strife
|
|
16
|
+
* insights endpoint and stored as time series, tagged by device class (mobile/desktop).
|
|
17
|
+
*
|
|
18
|
+
* The beacon carries a PUBLIC `site` id (this team's id, resolved server-side from
|
|
19
|
+
* the same Strife env the integration already reads) so the endpoint can attribute
|
|
20
|
+
* metrics to your workspace and validate the request Origin against your registered
|
|
21
|
+
* domains. No secret is ever exposed to the browser.
|
|
22
|
+
*/
|
|
23
|
+
// @ts-ignore — `astro:env/server` is a virtual module Astro provides in the
|
|
24
|
+
// consumer's runtime/build (see the integration's env schema).
|
|
25
|
+
import { getSecret } from 'astro:env/server';
|
|
26
|
+
import { decodeSecrets } from '@strifeapp/strife/secrets';
|
|
27
|
+
|
|
28
|
+
export interface Props {
|
|
29
|
+
/** Override the collection endpoint. Defaults to the hosted Strife API. */
|
|
30
|
+
endpoint?: string;
|
|
31
|
+
/** Set false to skip the axe-core accessibility audit (Web Vitals still reported). */
|
|
32
|
+
accessibility?: boolean;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const {
|
|
36
|
+
endpoint = 'https://api.strife.app/insights/collect',
|
|
37
|
+
accessibility = true,
|
|
38
|
+
} = Astro.props;
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Resolve this team's PUBLIC id from the same sources the integration reads: the
|
|
42
|
+
* consolidated STRIFE_SECRET blob (teamId), the legacy TEAM_ID env, or — last
|
|
43
|
+
* resort — the database name (`wieldy_{id}`). Returns null when no Strife env is
|
|
44
|
+
* configured, in which case the component renders nothing.
|
|
45
|
+
*/
|
|
46
|
+
function resolveSiteId(): string | null {
|
|
47
|
+
const blob = getSecret('STRIFE_SECRET');
|
|
48
|
+
if (blob) {
|
|
49
|
+
try {
|
|
50
|
+
const packed = decodeSecrets(blob);
|
|
51
|
+
if (packed?.teamId) return packed.teamId;
|
|
52
|
+
} catch {
|
|
53
|
+
// Malformed blob — fall through to the individual vars.
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
const teamId = getSecret('TEAM_ID');
|
|
57
|
+
if (teamId) return teamId;
|
|
58
|
+
const database = getSecret('STRIFE_DATABASE');
|
|
59
|
+
const match = database?.match(/^wieldy_(.+)$/);
|
|
60
|
+
return match ? match[1] : null;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const site = resolveSiteId();
|
|
64
|
+
|
|
65
|
+
// `editMode` is true inside the Strife live preview (set by the edit-mode
|
|
66
|
+
// middleware). We split the work by audience: Web Vitals run only for REAL visitors
|
|
67
|
+
// (they need real-user device/network/interaction variance), while the heavy
|
|
68
|
+
// axe-core accessibility audit runs only in the editor's live preview — a11y is
|
|
69
|
+
// deterministic per content version, so the editor is the right place for it, and it
|
|
70
|
+
// keeps axe-core off every real visitor entirely. Without the middleware, editMode
|
|
71
|
+
// defaults to false, so plain sites get Web Vitals exactly as before.
|
|
72
|
+
const editMode = Astro.locals.editMode ?? false;
|
|
73
|
+
---
|
|
74
|
+
|
|
75
|
+
{site && (
|
|
76
|
+
<Fragment>
|
|
77
|
+
<script is:inline define:vars={{ site, endpoint, accessibility, editMode }}>
|
|
78
|
+
window.__strifeInsights = { site, endpoint, accessibility, editMode };
|
|
79
|
+
</script>
|
|
80
|
+
<script>
|
|
81
|
+
import { onLCP, onINP, onCLS, onFCP, onTTFB } from 'web-vitals';
|
|
82
|
+
|
|
83
|
+
const cfg = (window as any).__strifeInsights || {};
|
|
84
|
+
const ENDPOINT: string = cfg.endpoint;
|
|
85
|
+
const SITE: string = cfg.site;
|
|
86
|
+
const RUN_A11Y: boolean = cfg.accessibility !== false;
|
|
87
|
+
const EDIT_MODE: boolean = cfg.editMode === true;
|
|
88
|
+
|
|
89
|
+
/** Beacon a payload to the collection endpoint (fire-and-forget). */
|
|
90
|
+
function send(payload: Record<string, unknown>) {
|
|
91
|
+
const body = JSON.stringify({ site: SITE, ...payload });
|
|
92
|
+
try {
|
|
93
|
+
// Send as a plain string → text/plain, a CORS-safelisted content type.
|
|
94
|
+
// That keeps the cross-origin beacon a "simple request" (no preflight),
|
|
95
|
+
// so it is delivered from ANY registered custom domain — not just the
|
|
96
|
+
// origins in the API's global CORS list. The server parses the body
|
|
97
|
+
// itself; we never read the response, so CORS never blocks the write.
|
|
98
|
+
if (navigator.sendBeacon && navigator.sendBeacon(ENDPOINT, body)) {
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
} catch {
|
|
102
|
+
// Fall through to fetch.
|
|
103
|
+
}
|
|
104
|
+
// No JSON content-type header here either, so the fallback stays
|
|
105
|
+
// preflight-free (a string body defaults to text/plain).
|
|
106
|
+
fetch(ENDPOINT, {
|
|
107
|
+
method: 'POST',
|
|
108
|
+
body,
|
|
109
|
+
keepalive: true,
|
|
110
|
+
}).catch(() => {});
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function sendAccessibilityMetrics(
|
|
114
|
+
metrics: Record<string, number>,
|
|
115
|
+
tag: string,
|
|
116
|
+
violations: unknown[],
|
|
117
|
+
) {
|
|
118
|
+
send({ page: window.location.pathname, metrics, tag, violations });
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Collect the finalised Core Web Vitals and send them as ONE batched beacon when
|
|
122
|
+
// the page is backgrounded/unloaded — the standard web-vitals RUM pattern.
|
|
123
|
+
// (reportAllChanges defaults to false, so each metric reports once.) Far fewer
|
|
124
|
+
// beacons + time-series writes than reporting on every change.
|
|
125
|
+
function initWebVitals() {
|
|
126
|
+
const speed: string | undefined = (navigator as any).connection?.effectiveType;
|
|
127
|
+
// Tag each metric by device class so Experience scores can be sliced by device
|
|
128
|
+
// in the Studio — same convention the accessibility audit uses.
|
|
129
|
+
const tag = navigator.userAgent.includes('Mobile') ? 'mobile' : 'desktop';
|
|
130
|
+
const metrics: Record<string, number> = {};
|
|
131
|
+
const add = (m: { name: string; value: number }) => {
|
|
132
|
+
metrics[m.name] = m.value;
|
|
133
|
+
};
|
|
134
|
+
onLCP(add);
|
|
135
|
+
onINP(add);
|
|
136
|
+
onCLS(add);
|
|
137
|
+
onFCP(add);
|
|
138
|
+
onTTFB(add);
|
|
139
|
+
|
|
140
|
+
const flush = () => {
|
|
141
|
+
const names = Object.keys(metrics);
|
|
142
|
+
if (names.length === 0) return;
|
|
143
|
+
// Read the pathname at flush time, not at init time: on Astro View Transition
|
|
144
|
+
// sites the URL changes via soft nav without re-running initWebVitals, so this
|
|
145
|
+
// attributes the batch to the page actually being left rather than the first one.
|
|
146
|
+
const page = window.location.pathname;
|
|
147
|
+
const batch: Record<string, number> = {};
|
|
148
|
+
for (const name of names) {
|
|
149
|
+
batch[name] = metrics[name];
|
|
150
|
+
delete metrics[name]; // send each metric at most once across flushes
|
|
151
|
+
}
|
|
152
|
+
send({ page, metrics: batch, speed, tag });
|
|
153
|
+
};
|
|
154
|
+
addEventListener('visibilitychange', () => {
|
|
155
|
+
if (document.visibilityState === 'hidden') flush();
|
|
156
|
+
});
|
|
157
|
+
addEventListener('pagehide', flush);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
async function runAccessibilityAudit() {
|
|
161
|
+
try {
|
|
162
|
+
// axe-core is CJS; the ESM interop varies by bundler/consumer — the API lives
|
|
163
|
+
// on the default export in some setups, on the namespace itself in others, so
|
|
164
|
+
// handle both rather than crash on `undefined.run`.
|
|
165
|
+
const axeModule = await import('axe-core');
|
|
166
|
+
const axe: any = (axeModule as any).default ?? axeModule;
|
|
167
|
+
const results = await axe.run(document, { resultTypes: ['violations'] });
|
|
168
|
+
|
|
169
|
+
const byImpact = { critical: 0, serious: 0, moderate: 0, minor: 0 };
|
|
170
|
+
for (const violation of results.violations) {
|
|
171
|
+
const impact = violation.impact as keyof typeof byImpact;
|
|
172
|
+
if (impact && impact in byImpact) byImpact[impact] += violation.nodes.length;
|
|
173
|
+
}
|
|
174
|
+
const total = byImpact.critical + byImpact.serious + byImpact.moderate + byImpact.minor;
|
|
175
|
+
const penalty =
|
|
176
|
+
byImpact.critical * 10 + byImpact.serious * 5 + byImpact.moderate * 2 + byImpact.minor * 1;
|
|
177
|
+
const score = Math.max(0, 100 - penalty);
|
|
178
|
+
|
|
179
|
+
// Compact, capped violation details for the "what's actually wrong" list.
|
|
180
|
+
// axe-core categories live in the `cat.*` tag (e.g. `cat.color` → `color`).
|
|
181
|
+
const details = results.violations
|
|
182
|
+
.map((v) => {
|
|
183
|
+
const cat = (v.tags || []).find((t: string) => t.indexOf('cat.') === 0);
|
|
184
|
+
const firstTarget = (v.nodes[0] as any)?.target;
|
|
185
|
+
return {
|
|
186
|
+
id: v.id,
|
|
187
|
+
impact: v.impact || 'minor',
|
|
188
|
+
category: cat ? cat.slice(4) : 'other',
|
|
189
|
+
help: v.help,
|
|
190
|
+
helpUrl: v.helpUrl,
|
|
191
|
+
count: v.nodes.length,
|
|
192
|
+
sample: Array.isArray(firstTarget) ? firstTarget.join(' ') : '',
|
|
193
|
+
};
|
|
194
|
+
})
|
|
195
|
+
.slice(0, 50);
|
|
196
|
+
|
|
197
|
+
sendAccessibilityMetrics(
|
|
198
|
+
{
|
|
199
|
+
A11Y_SCORE: score,
|
|
200
|
+
A11Y_VIOLATIONS: total,
|
|
201
|
+
A11Y_CRITICAL: byImpact.critical,
|
|
202
|
+
A11Y_SERIOUS: byImpact.serious,
|
|
203
|
+
A11Y_MODERATE: byImpact.moderate,
|
|
204
|
+
A11Y_MINOR: byImpact.minor,
|
|
205
|
+
},
|
|
206
|
+
navigator.userAgent.includes('Mobile') ? 'mobile' : 'desktop',
|
|
207
|
+
details,
|
|
208
|
+
);
|
|
209
|
+
} catch (error) {
|
|
210
|
+
// Best-effort — accessibility metrics never break the page.
|
|
211
|
+
console.warn('[Insights] Accessibility audit failed:', error);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function scheduleAccessibilityAudit() {
|
|
216
|
+
if ('requestIdleCallback' in window) {
|
|
217
|
+
(window as any).requestIdleCallback(() => runAccessibilityAudit(), { timeout: 10000 });
|
|
218
|
+
} else {
|
|
219
|
+
setTimeout(runAccessibilityAudit, 3000);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// Track audited pages to avoid duplicate audits on soft navigations.
|
|
224
|
+
const auditedPages = new Set<string>();
|
|
225
|
+
|
|
226
|
+
function auditOnce() {
|
|
227
|
+
if (!RUN_A11Y) return;
|
|
228
|
+
const path = window.location.pathname;
|
|
229
|
+
if (auditedPages.has(path)) return;
|
|
230
|
+
auditedPages.add(path);
|
|
231
|
+
scheduleAccessibilityAudit();
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
if (EDIT_MODE) {
|
|
235
|
+
// In the Strife live preview: only the accessibility audit runs. The editor is
|
|
236
|
+
// the right place for the heavy axe-core scan (a11y is deterministic per content
|
|
237
|
+
// version), and it keeps axe-core off every real visitor.
|
|
238
|
+
auditOnce();
|
|
239
|
+
// Astro View Transitions: re-audit the newly loaded page.
|
|
240
|
+
document.addEventListener('astro:page-load', auditOnce);
|
|
241
|
+
} else {
|
|
242
|
+
// A real visitor: Web Vitals only, batched on pagehide. No axe-core download/run.
|
|
243
|
+
initWebVitals();
|
|
244
|
+
}
|
|
245
|
+
</script>
|
|
246
|
+
</Fragment>
|
|
247
|
+
)}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@strifeapp/astro",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.4.1",
|
|
4
4
|
"description": "Official Strife integration for Astro — connect your Astro site to a RavenDB-backed Strife content store via a strife:store virtual module.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"astro-integration",
|
|
@@ -40,6 +40,7 @@
|
|
|
40
40
|
"import": "./dist/edit-mode-middleware.js"
|
|
41
41
|
},
|
|
42
42
|
"./LivePreview.astro": "./dist/LivePreview.astro",
|
|
43
|
+
"./Insights.astro": "./dist/Insights.astro",
|
|
43
44
|
"./vite-plugin-strife-store": "./dist/vite-plugin-strife-store-entry.js"
|
|
44
45
|
},
|
|
45
46
|
"files": [
|
|
@@ -60,10 +61,12 @@
|
|
|
60
61
|
},
|
|
61
62
|
"dependencies": {
|
|
62
63
|
"@strifeapp/strife": "^1.1.0",
|
|
64
|
+
"axe-core": "^4.11.0",
|
|
63
65
|
"dotenv": "^17.2.3",
|
|
64
66
|
"jose": "^5.9.6",
|
|
65
67
|
"ravendb": "^7.1.4",
|
|
66
|
-
"serialize-javascript": "^7.0.5"
|
|
68
|
+
"serialize-javascript": "^7.0.5",
|
|
69
|
+
"web-vitals": "^5.1.0"
|
|
67
70
|
},
|
|
68
71
|
"devDependencies": {
|
|
69
72
|
"@types/serialize-javascript": "^5.0.4",
|