@getvitops/astro 1.0.0 → 2.1.0
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/CHANGELOG.md +599 -0
- package/README.md +222 -3
- package/dist/index.d.mts +422 -5
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +637 -15
- package/dist/index.mjs.map +1 -1
- package/package.json +12 -5
- package/src/analytics.ts +325 -0
- package/src/components/Analytics.astro +59 -0
- package/src/components/CookieConsent.astro +106 -0
- package/src/components/Details.astro +10 -4
- package/src/components/Drawer.astro +9 -3
- package/src/components/Head.astro +46 -1
- package/src/components/Icon.astro +94 -0
- package/src/components/Popover.astro +9 -3
- package/src/seo.ts +33 -3
- package/virtual.d.ts +49 -0
package/README.md
CHANGED
|
@@ -50,19 +50,77 @@ Astro's module graph and Astro emits that link itself.
|
|
|
50
50
|
| option | default | does |
|
|
51
51
|
| --------------- | -------------------- | ----------------------------------------------------------------- |
|
|
52
52
|
| `css` | off unless given | generate + auto-inject the design-system CSS |
|
|
53
|
-
| `css.input` | `design-system.json` | source config
|
|
53
|
+
| `css.input` | `design-system.json` | source config — a design-system.json, or a site config |
|
|
54
|
+
| `css.theme` | the config's default | `designSystem.themes` entry to build, for a site-config `input` |
|
|
54
55
|
| `css.format` | `tailwind` | `tailwind` \| `css` \| `bricks` |
|
|
55
56
|
| `css.out` | `src/styles` | directory the generated CSS is written to |
|
|
56
57
|
| `css.inject` | `true` | inject the stylesheet into every SSR page |
|
|
57
58
|
| `webComponents` | `true` | copy + link the web-component bundles |
|
|
58
59
|
| `favicon` | off unless given | `source`, `lowResSource`, `name`, `themeColor`, `backgroundColor` |
|
|
60
|
+
| `media` | off unless given | encode raw video to WebM + MP4 + poster (needs `css`, `ffmpeg`) |
|
|
59
61
|
| `sitemap` | off unless given | generate `sitemap-index.xml` via `@astrojs/sitemap` |
|
|
60
62
|
| `seo` | off unless given | site-level defaults for `<Seo />` |
|
|
63
|
+
| `analytics` | off unless given | providers for `<Analytics />` (GA4, Clarity, Matomo, Plausible) |
|
|
64
|
+
| `consent` | off unless given | the consent gate + `<CookieConsent />` |
|
|
65
|
+
|
|
66
|
+
**`css.input` takes either config kind.** Point it at a `design-system.json`, or at the larger site
|
|
67
|
+
config that embeds one (`company.json` / `site.json`) — they're told apart by shape, so the file can
|
|
68
|
+
be called anything. A site config also supplies what `site`, `legal` and `fonts` would otherwise need
|
|
69
|
+
their own path for, so `legal: {}` and `fonts: true` become the whole declaration.
|
|
61
70
|
|
|
62
71
|
Set `css.inject: false` when another integration adds routes that must not inherit the design system
|
|
63
72
|
(e.g. EmDash's `/_emdash/admin`) — then import the generated file (`<out>/tailwind.css` or
|
|
64
73
|
`<out>/styles.css`) from your own layout, so only your pages are styled.
|
|
65
74
|
|
|
75
|
+
## Video
|
|
76
|
+
|
|
77
|
+
`media` encodes everything in a `raw/` directory into VP9/WebM, an H.264/MP4 fallback, and a JPG
|
|
78
|
+
poster frame:
|
|
79
|
+
|
|
80
|
+
```js
|
|
81
|
+
vitops({
|
|
82
|
+
css: { format: 'css' },
|
|
83
|
+
media: { raw: 'raw', out: 'src/assets/processed' },
|
|
84
|
+
});
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
Import the results like any other asset, so Vite content-hashes them:
|
|
88
|
+
|
|
89
|
+
```astro
|
|
90
|
+
---
|
|
91
|
+
import hero from '../assets/processed/hero.webm';
|
|
92
|
+
import poster from '../assets/processed/hero.jpg';
|
|
93
|
+
---
|
|
94
|
+
<video poster={poster.src} autoplay muted loop playsinline>
|
|
95
|
+
<source src={hero} type="video/webm" />
|
|
96
|
+
</video>
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
Outputs go under `src/` for that hashing; the raw sources stay outside anything Vite scans.
|
|
100
|
+
|
|
101
|
+
**`ffmpeg` is an external tool — install it yourself:** `brew install ffmpeg`,
|
|
102
|
+
`apt install ffmpeg`, `winget install Gyan.FFmpeg`. Without it the build fails with a message
|
|
103
|
+
telling you so, rather than quietly shipping a page that points at a video nobody encoded.
|
|
104
|
+
|
|
105
|
+
Four things are deliberate:
|
|
106
|
+
|
|
107
|
+
- **Commit the outputs and `.vitops/media-manifest.json`.** Encoding is cached on source content
|
|
108
|
+
plus settings, so a rebuild costs a hash per file — but a fresh CI clone has neither and would
|
|
109
|
+
re-encode everything. Committing both means CI never runs ffmpeg. It also keeps history clean:
|
|
110
|
+
ffmpeg output isn't reproducible across versions, so a CI re-encode rewrites every video on any
|
|
111
|
+
toolchain bump.
|
|
112
|
+
- **The raw directory isn't watched.** It runs on build and on a config change, not on a file
|
|
113
|
+
appearing mid-`astro dev` — a multi-minute encode stalling your dev server is a worse trade than
|
|
114
|
+
a restart. Drop a video in and restart, or run `npx vitops media`.
|
|
115
|
+
- **An MP4 sits beside the WebM on purpose.** Older iOS and the in-app webviews inside social apps
|
|
116
|
+
still don't decode VP9. The MP4 is written with `+faststart`, without which the browser
|
|
117
|
+
downloads the whole file before showing frame one.
|
|
118
|
+
- **Audio is dropped by default.** The common case is a muted autoplay loop, where the audio track
|
|
119
|
+
is bytes nobody will ever hear. `media: { audio: true }` keeps it.
|
|
120
|
+
|
|
121
|
+
Needs `css`, since the encoding runs in the Vite plugin `css` registers — it warns if you set one
|
|
122
|
+
without the other. For any other stack, `npx vitops media` does the same job with no integration.
|
|
123
|
+
|
|
66
124
|
## Sitemap
|
|
67
125
|
|
|
68
126
|
`sitemap: true` registers the official [`@astrojs/sitemap`](https://docs.astro.build/en/guides/integrations-guide/sitemap/)
|
|
@@ -89,6 +147,33 @@ content _and_ hand-authored `.astro` pages), add `sitemap()` to your own `integr
|
|
|
89
147
|
vitops detects that too and leaves yours in charge, which is also how you reach the handful of
|
|
90
148
|
`@astrojs/sitemap` options this integration doesn't mirror.
|
|
91
149
|
|
|
150
|
+
### Real `<lastmod>` dates
|
|
151
|
+
|
|
152
|
+
A sitemap without `<lastmod>` tells a crawler a page exists but never that it changed. `gitLastmod()`
|
|
153
|
+
stamps each entry from its source file's last commit:
|
|
154
|
+
|
|
155
|
+
```js
|
|
156
|
+
import vitops, { gitLastmod } from '@getvitops/astro';
|
|
157
|
+
|
|
158
|
+
export default defineConfig({
|
|
159
|
+
site: 'https://acme.ca',
|
|
160
|
+
integrations: [vitops({ sitemap: { serialize: await gitLastmod() } })],
|
|
161
|
+
});
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
It maps file-based routes exactly (`src/pages/about.astro` → `/about`) and content entries by unique
|
|
165
|
+
slug. Three deliberate refusals, all of the same kind — **no date beats a wrong one**, because Google
|
|
166
|
+
weighs `lastmod` only while it stays consistent with what actually changed:
|
|
167
|
+
|
|
168
|
+
- **Dynamic routes get nothing.** One `[slug].astro` backs many URLs; its commit date describes the
|
|
169
|
+
template, not any page.
|
|
170
|
+
- **An ambiguous slug gets nothing** rather than a coin flip presented as a fact.
|
|
171
|
+
- **A shallow clone gets nothing, loudly.** It shells out to `git`, so `fetch-depth: 1` (the
|
|
172
|
+
actions/checkout default) yields no history — it warns and emits no dates. Set `fetch-depth: 0`.
|
|
173
|
+
|
|
174
|
+
This is also what makes `vitops indexing` able to submit only what changed, rather than everything on
|
|
175
|
+
every deploy.
|
|
176
|
+
|
|
92
177
|
## `<Seo />`
|
|
93
178
|
|
|
94
179
|
Page metadata: `<title>`, description, canonical, Open Graph, Twitter cards, robots, `article:*`,
|
|
@@ -130,8 +215,11 @@ web-component runtime); use both.
|
|
|
130
215
|
|
|
131
216
|
Behaviour worth knowing:
|
|
132
217
|
|
|
133
|
-
- **`titleTemplate`
|
|
134
|
-
|
|
218
|
+
- **`titleTemplate` applies to `<title>` only.** `og:title`/`twitter:title` get the _untemplated_
|
|
219
|
+
title, because a social card already shows `og:site_name` and the domain — "Pricing · Acme" would
|
|
220
|
+
sit directly above "Acme". Pass `ogTitle` for a different social headline. The template is also
|
|
221
|
+
skipped when a page's title already equals `siteName`, so a homepage titled "Acme" emits `Acme`,
|
|
222
|
+
not `Acme · Acme`, and it never applies to `defaultTitle`.
|
|
135
223
|
- **Canonical needs the `site` astro.config option.** Without it, canonical, `og:url` and any
|
|
136
224
|
_relative_ `og:image` are omitted rather than derived from the request — a canonical built from a
|
|
137
225
|
dev or preview origin can de-index you. The integration warns at build time. Absolute image URLs
|
|
@@ -155,6 +243,137 @@ both duplicates every one of them; the integration warns if you configure `seo`
|
|
|
155
243
|
The merge logic is a pure function, exported as `resolveSeo(defaults, props, ctx)` if you need to
|
|
156
244
|
drive it yourself.
|
|
157
245
|
|
|
246
|
+
## Analytics
|
|
247
|
+
|
|
248
|
+
`<Analytics />` emits the tags for the providers you configure. Nothing touches the critical path,
|
|
249
|
+
and anything that sets cookies waits for consent.
|
|
250
|
+
|
|
251
|
+
```js
|
|
252
|
+
// astro.config.mjs
|
|
253
|
+
vitops({
|
|
254
|
+
analytics: {
|
|
255
|
+
googleAnalytics: 'G-XXXXXXXXXX',
|
|
256
|
+
clarity: 'abcd1234',
|
|
257
|
+
matomo: { url: 'https://stats.acme.com', siteId: '1' },
|
|
258
|
+
plausible: 'acme.com',
|
|
259
|
+
strategy: 'idle',
|
|
260
|
+
},
|
|
261
|
+
consent: { policyUrl: '/legal/cookies' },
|
|
262
|
+
});
|
|
263
|
+
```
|
|
264
|
+
|
|
265
|
+
```astro
|
|
266
|
+
---
|
|
267
|
+
import Analytics from '@getvitops/astro/Analytics.astro';
|
|
268
|
+
import CookieConsent from '@getvitops/astro/CookieConsent.astro';
|
|
269
|
+
---
|
|
270
|
+
<head>
|
|
271
|
+
<Head />
|
|
272
|
+
<Analytics />
|
|
273
|
+
</head>
|
|
274
|
+
<body>
|
|
275
|
+
<slot />
|
|
276
|
+
<CookieConsent />
|
|
277
|
+
</body>
|
|
278
|
+
```
|
|
279
|
+
|
|
280
|
+
| provider | sets cookies | consent category | notes |
|
|
281
|
+
| ---------------------- | ------------ | ---------------- | -------------------------------------------------------------------------------- |
|
|
282
|
+
| **Google Analytics** 4 | yes | `analytics` | `_ga`, `_ga_*`, `_gid`. `category: 'marketing'` if the property feeds Ads |
|
|
283
|
+
| **Microsoft Clarity** | yes | `analytics` | session replay + heatmaps; `_clck`, `_clsk`, `MUID` |
|
|
284
|
+
| **Matomo** | **no** | `necessary` | `disableCookies` by default; `cookies: true` opts in and moves it to `analytics` |
|
|
285
|
+
| **Plausible** | no | `necessary` | cookieless, ~1 KB |
|
|
286
|
+
|
|
287
|
+
**The category is derived, not declared.** It follows from whether the provider sets cookies, which
|
|
288
|
+
follows from that provider's own configuration. You can't mark Google Analytics `necessary` to skip
|
|
289
|
+
the banner — but you _can_ pick a genuinely cookieless provider and be done with it, which is the
|
|
290
|
+
choice the table is trying to make legible.
|
|
291
|
+
|
|
292
|
+
### Loading
|
|
293
|
+
|
|
294
|
+
`strategy` decides when a tag runs. The default keeps analytics off the critical path entirely:
|
|
295
|
+
|
|
296
|
+
| `strategy` | loads |
|
|
297
|
+
| ---------------- | ---------------------------------------------------------------- |
|
|
298
|
+
| `idle` (default) | after `load`, on an idle callback (3s timeout) |
|
|
299
|
+
| `async` | immediately, with the vendor's own `async` semantics |
|
|
300
|
+
| `interaction` | on first pointer/key/scroll, or after 8s — whichever comes first |
|
|
301
|
+
|
|
302
|
+
`interaction` is the cheapest and the least accurate: a visitor who reads and leaves is counted only
|
|
303
|
+
by the 8s fallback. No `preconnect` is emitted for any of them — warming a third-party connection
|
|
304
|
+
during parse is exactly the cost `idle` exists to avoid.
|
|
305
|
+
|
|
306
|
+
### How consent actually blocks a tag
|
|
307
|
+
|
|
308
|
+
Gated tags render as `<script type="text/plain">` with the URL on `data-src`. The browser never parses
|
|
309
|
+
the body and never fetches the library, so an undecided or declining visitor's page issues **no
|
|
310
|
+
third-party request at all**. Consent implemented by asking a tracker not to track is a promise; this
|
|
311
|
+
is a fact about the document.
|
|
312
|
+
|
|
313
|
+
For Google Analytics that means **basic consent mode, not advanced**: nothing reaches Google until the
|
|
314
|
+
visitor accepts, rather than loading immediately with signals denied to send cookieless pings. Fewer
|
|
315
|
+
modelled conversions, nothing to defend. Clarity is gated the same way and additionally receives
|
|
316
|
+
`clarity('consentv2', …)`, because Microsoft enforces the signal separately for EEA/UK/CH traffic.
|
|
317
|
+
|
|
318
|
+
## Cookie consent
|
|
319
|
+
|
|
320
|
+
`consent: true` ships `@getvitops/core/consent` — a 2.3 KB gzipped, Lit-free bundle — and enables
|
|
321
|
+
`<CookieConsent />`.
|
|
322
|
+
|
|
323
|
+
**It is not an analytics feature.** The gate is general: mark anything `data-consent="<category>"`
|
|
324
|
+
and it waits on the same choice.
|
|
325
|
+
|
|
326
|
+
```html
|
|
327
|
+
<script type="text/plain" data-vitops-tag data-consent="marketing" data-src="https://…"></script>
|
|
328
|
+
<iframe data-consent="marketing" data-consent-src="https://www.youtube.com/embed/…"></iframe>
|
|
329
|
+
```
|
|
330
|
+
|
|
331
|
+
Categories are `necessary` (always granted), `analytics`, `marketing`, `preferences`. The banner
|
|
332
|
+
offers only the ones something is actually waiting on.
|
|
333
|
+
|
|
334
|
+
Anything else — A/B assignment, account personalisation, your own scripts — uses `window.vitopsConsent`:
|
|
335
|
+
|
|
336
|
+
```js
|
|
337
|
+
window.vitopsConsent.subscribe((state) => {
|
|
338
|
+
if (window.vitopsConsent.granted('preferences')) restoreSavedLayout();
|
|
339
|
+
});
|
|
340
|
+
```
|
|
341
|
+
|
|
342
|
+
`get()` · `granted(category)` · `needed()` · `set({ analytics: true })` · `acceptAll()` ·
|
|
343
|
+
`rejectAll()` · `reset()` · `open()` · `subscribe(fn)`. A `vitops:consent` event fires on `document`
|
|
344
|
+
at startup and on every change. Anything with `[data-consent-open]` reopens the banner, so a footer
|
|
345
|
+
"Cookie settings" link needs no JS of its own.
|
|
346
|
+
|
|
347
|
+
Behaviour worth knowing:
|
|
348
|
+
|
|
349
|
+
- **Nothing is stored until the visitor chooses.** No cookie, no localStorage. Showing the banner
|
|
350
|
+
can't be the thing that needs consent. An unreadable or wrong-version cookie re-prompts rather than
|
|
351
|
+
being read permissively.
|
|
352
|
+
- **Revoking clears cookies and reloads.** An already-executing tracker can't be unloaded any other
|
|
353
|
+
way. `<CookieConsent noReloadOnRevoke />` turns the reload off, at the cost of that tracker running
|
|
354
|
+
until the next navigation.
|
|
355
|
+
- **Rejecting is a decision** — the banner stays gone, it doesn't keep asking until told yes.
|
|
356
|
+
- **No geo detection.** The banner shows for everyone once enabled. Suppressing it from a timezone or
|
|
357
|
+
an IP guess fails toward _not asking_, which is the expensive direction to be wrong in.
|
|
358
|
+
- **With no JS, nothing happens and that's correct** — the gate never runs, so no gated tag loads and
|
|
359
|
+
no non-essential cookie is set. The banner stays hidden because there is nothing to consent to.
|
|
360
|
+
- **`<Analytics />` alone still uses the runtime** for scheduling, unless every provider is cookieless
|
|
361
|
+
_and_ `strategy: 'async'` — the one configuration that ships no consent JavaScript at all.
|
|
362
|
+
|
|
363
|
+
### Keep it in step with your cookie notice
|
|
364
|
+
|
|
365
|
+
`vitops legal` derives the privacy policy and cookie notice from your **site config**, and
|
|
366
|
+
`getvitops({ analytics })` is a separate surface. Declare each provider in both:
|
|
367
|
+
|
|
368
|
+
```jsonc
|
|
369
|
+
// site config — what the documents disclose
|
|
370
|
+
{ "analytics": { "googleAnalyticsId": "G-XXXXXXXXXX", "clarityId": "abcd1234" } }
|
|
371
|
+
```
|
|
372
|
+
|
|
373
|
+
Configure `legal` alongside `analytics` and the integration checks this for you, naming any provider
|
|
374
|
+
you'd otherwise be running without disclosing. It also warns when a cookie-setting provider is
|
|
375
|
+
configured with no `consent` gate.
|
|
376
|
+
|
|
158
377
|
## Components
|
|
159
378
|
|
|
160
379
|
Thin authoring helpers that emit the framework's HTML/CSS patterns — **none require runtime JS**:
|