@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/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` is skipped when a page's title already equals `siteName`**, so a homepage titled
134
- "Acme" emits `Acme`, not `Acme · Acme`. It never applies to `defaultTitle` either.
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**: