@getvitops/astro 0.9.0 → 2.0.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
@@ -15,11 +15,11 @@ npm i -D @getvitops/astro
15
15
  ```js
16
16
  // astro.config.mjs
17
17
  import { defineConfig } from 'astro/config';
18
- import getvitops from '@getvitops/astro';
18
+ import vitops from '@getvitops/astro';
19
19
 
20
20
  export default defineConfig({
21
21
  integrations: [
22
- getvitops({
22
+ vitops({
23
23
  css: { input: 'design-system.json', format: 'tailwind', out: 'src/styles' },
24
24
  favicon: { source: 'src/assets/logo.svg', name: 'My Site', themeColor: '#0b0b0c' },
25
25
  }),
@@ -50,17 +50,253 @@ 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
+ | `sitemap` | off unless given | generate `sitemap-index.xml` via `@astrojs/sitemap` |
61
+ | `seo` | off unless given | site-level defaults for `<Seo />` |
62
+ | `analytics` | off unless given | providers for `<Analytics />` (GA4, Clarity, Matomo, Plausible) |
63
+ | `consent` | off unless given | the consent gate + `<CookieConsent />` |
64
+
65
+ **`css.input` takes either config kind.** Point it at a `design-system.json`, or at the larger site
66
+ config that embeds one (`company.json` / `site.json`) — they're told apart by shape, so the file can
67
+ be called anything. A site config also supplies what `site`, `legal` and `fonts` would otherwise need
68
+ their own path for, so `legal: {}` and `fonts: true` become the whole declaration.
59
69
 
60
70
  Set `css.inject: false` when another integration adds routes that must not inherit the design system
61
71
  (e.g. EmDash's `/_emdash/admin`) — then import the generated file (`<out>/tailwind.css` or
62
72
  `<out>/styles.css`) from your own layout, so only your pages are styled.
63
73
 
74
+ ## Sitemap
75
+
76
+ `sitemap: true` registers the official [`@astrojs/sitemap`](https://docs.astro.build/en/guides/integrations-guide/sitemap/)
77
+ and links the result from `<Head />`. Pass an object to configure it (`filter`, `customPages`,
78
+ `changefreq`, `priority`, `i18n`, `entryLimit`, `filenameBase`, `serialize`, …):
79
+
80
+ ```js
81
+ vitops({ sitemap: { filter: (page) => !page.includes('/draft/') } });
82
+ ```
83
+
84
+ Three things to know:
85
+
86
+ - **It's an optional peer — install it yourself:** `pnpm add -D @astrojs/sitemap`. Without it the
87
+ build fails with a message telling you so, rather than silently emitting nothing.
88
+ - **It needs the `site` astro.config option**, since a sitemap lists absolute URLs. Without it the
89
+ option warns and skips.
90
+ - **It lists prerendered routes only.** On an `output: 'server'` site, mark the pages you want
91
+ indexed with `export const prerender = true`, or list them in `sitemap.customPages`.
92
+
93
+ **On an EmDash site, leave it off.** EmDash serves its own `/sitemap.xml` from the database, which
94
+ also covers on-demand pages a static sitemap can't; the option detects `emdash()` and skips with a
95
+ warning. The two write different filenames and so don't actually collide — if you want both (DB
96
+ content _and_ hand-authored `.astro` pages), add `sitemap()` to your own `integrations` array.
97
+ vitops detects that too and leaves yours in charge, which is also how you reach the handful of
98
+ `@astrojs/sitemap` options this integration doesn't mirror.
99
+
100
+ ## `<Seo />`
101
+
102
+ Page metadata: `<title>`, description, canonical, Open Graph, Twitter cards, robots, `article:*`,
103
+ `hreflang`, verification tokens. Site-level defaults go in the integration; pages pass what differs.
104
+
105
+ ```js
106
+ // astro.config.mjs
107
+ vitops({
108
+ seo: {
109
+ siteName: 'Acme',
110
+ titleTemplate: '%s · Acme',
111
+ defaultDescription: 'We make the thing.',
112
+ openGraph: {
113
+ locale: 'en_CA',
114
+ image: { url: '/og.png', alt: 'Acme', width: 1200, height: 630 },
115
+ },
116
+ twitter: { site: '@acme' },
117
+ },
118
+ });
119
+ ```
120
+
121
+ ```astro
122
+ ---
123
+ import Head from '@getvitops/astro/Head.astro';
124
+ import Seo from '@getvitops/astro/Seo.astro';
125
+ ---
126
+ <head>
127
+ <meta charset="utf-8" />
128
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
129
+ <Seo title={title} description={description} image={cover} />
130
+ <Head />
131
+ </head>
132
+ ```
133
+
134
+ **It owns `<title>` and `<meta name="description">` — delete yours when you adopt it.** That isn't
135
+ tidiness: it already computes the resolved title for `og:title`/`twitter:title`, and splitting the
136
+ two is how they drift out of sync. `<Head />` is orthogonal (favicons, theme-color, the
137
+ web-component runtime); use both.
138
+
139
+ Behaviour worth knowing:
140
+
141
+ - **`titleTemplate` applies to `<title>` only.** `og:title`/`twitter:title` get the _untemplated_
142
+ title, because a social card already shows `og:site_name` and the domain — "Pricing · Acme" would
143
+ sit directly above "Acme". Pass `ogTitle` for a different social headline. The template is also
144
+ skipped when a page's title already equals `siteName`, so a homepage titled "Acme" emits `Acme`,
145
+ not `Acme · Acme`, and it never applies to `defaultTitle`.
146
+ - **Canonical needs the `site` astro.config option.** Without it, canonical, `og:url` and any
147
+ _relative_ `og:image` are omitted rather than derived from the request — a canonical built from a
148
+ dev or preview origin can de-index you. The integration warns at build time. Absolute image URLs
149
+ still work.
150
+ - **`base` is handled for you.** `Astro.url.pathname` already includes it; don't prepend it yourself.
151
+ Under the default `trailingSlash: 'ignore'` dev and build can disagree on the trailing slash, so
152
+ pin `'always'` or `'never'` if you need a byte-stable canonical.
153
+ - **`robots` is omitted unless it says something.** `index, follow` is what crawlers already assume.
154
+ Set `noindex`/`nofollow`/`noarchive`/`nocache`/`robotsExtras` per page, `robots` for a full
155
+ override, or `seo.robots` for a site-wide default.
156
+ - **`twitter:card`** becomes `summary_large_image` whenever an image resolves.
157
+ - **`hreflang` alternates are explicit only.** Pass `alternates={[{ hreflang, href }, …]}` including
158
+ the current page; nothing is inferred from a locale list, because only the page knows where its own
159
+ translations live.
160
+ - **No JSON-LD.** The structured-data components in `./schemas/` take _entity_ data, `<Seo />` takes
161
+ _page_ data. Compose them: `<Seo … />` then `<Organization {...org} />`.
162
+
163
+ **On an EmDash site, use `<EmDashHead>` instead** — it emits the same tags from the CMS. Rendering
164
+ both duplicates every one of them; the integration warns if you configure `seo` alongside `emdash()`.
165
+
166
+ The merge logic is a pure function, exported as `resolveSeo(defaults, props, ctx)` if you need to
167
+ drive it yourself.
168
+
169
+ ## Analytics
170
+
171
+ `<Analytics />` emits the tags for the providers you configure. Nothing touches the critical path,
172
+ and anything that sets cookies waits for consent.
173
+
174
+ ```js
175
+ // astro.config.mjs
176
+ vitops({
177
+ analytics: {
178
+ googleAnalytics: 'G-XXXXXXXXXX',
179
+ clarity: 'abcd1234',
180
+ matomo: { url: 'https://stats.acme.com', siteId: '1' },
181
+ plausible: 'acme.com',
182
+ strategy: 'idle',
183
+ },
184
+ consent: { policyUrl: '/legal/cookies' },
185
+ });
186
+ ```
187
+
188
+ ```astro
189
+ ---
190
+ import Analytics from '@getvitops/astro/Analytics.astro';
191
+ import CookieConsent from '@getvitops/astro/CookieConsent.astro';
192
+ ---
193
+ <head>
194
+ <Head />
195
+ <Analytics />
196
+ </head>
197
+ <body>
198
+ <slot />
199
+ <CookieConsent />
200
+ </body>
201
+ ```
202
+
203
+ | provider | sets cookies | consent category | notes |
204
+ | ---------------------- | ------------ | ---------------- | -------------------------------------------------------------------------------- |
205
+ | **Google Analytics** 4 | yes | `analytics` | `_ga`, `_ga_*`, `_gid`. `category: 'marketing'` if the property feeds Ads |
206
+ | **Microsoft Clarity** | yes | `analytics` | session replay + heatmaps; `_clck`, `_clsk`, `MUID` |
207
+ | **Matomo** | **no** | `necessary` | `disableCookies` by default; `cookies: true` opts in and moves it to `analytics` |
208
+ | **Plausible** | no | `necessary` | cookieless, ~1 KB |
209
+
210
+ **The category is derived, not declared.** It follows from whether the provider sets cookies, which
211
+ follows from that provider's own configuration. You can't mark Google Analytics `necessary` to skip
212
+ the banner — but you _can_ pick a genuinely cookieless provider and be done with it, which is the
213
+ choice the table is trying to make legible.
214
+
215
+ ### Loading
216
+
217
+ `strategy` decides when a tag runs. The default keeps analytics off the critical path entirely:
218
+
219
+ | `strategy` | loads |
220
+ | ---------------- | ---------------------------------------------------------------- |
221
+ | `idle` (default) | after `load`, on an idle callback (3s timeout) |
222
+ | `async` | immediately, with the vendor's own `async` semantics |
223
+ | `interaction` | on first pointer/key/scroll, or after 8s — whichever comes first |
224
+
225
+ `interaction` is the cheapest and the least accurate: a visitor who reads and leaves is counted only
226
+ by the 8s fallback. No `preconnect` is emitted for any of them — warming a third-party connection
227
+ during parse is exactly the cost `idle` exists to avoid.
228
+
229
+ ### How consent actually blocks a tag
230
+
231
+ Gated tags render as `<script type="text/plain">` with the URL on `data-src`. The browser never parses
232
+ the body and never fetches the library, so an undecided or declining visitor's page issues **no
233
+ third-party request at all**. Consent implemented by asking a tracker not to track is a promise; this
234
+ is a fact about the document.
235
+
236
+ For Google Analytics that means **basic consent mode, not advanced**: nothing reaches Google until the
237
+ visitor accepts, rather than loading immediately with signals denied to send cookieless pings. Fewer
238
+ modelled conversions, nothing to defend. Clarity is gated the same way and additionally receives
239
+ `clarity('consentv2', …)`, because Microsoft enforces the signal separately for EEA/UK/CH traffic.
240
+
241
+ ## Cookie consent
242
+
243
+ `consent: true` ships `@getvitops/core/consent` — a 2.3 KB gzipped, Lit-free bundle — and enables
244
+ `<CookieConsent />`.
245
+
246
+ **It is not an analytics feature.** The gate is general: mark anything `data-consent="<category>"`
247
+ and it waits on the same choice.
248
+
249
+ ```html
250
+ <script type="text/plain" data-vitops-tag data-consent="marketing" data-src="https://…"></script>
251
+ <iframe data-consent="marketing" data-consent-src="https://www.youtube.com/embed/…"></iframe>
252
+ ```
253
+
254
+ Categories are `necessary` (always granted), `analytics`, `marketing`, `preferences`. The banner
255
+ offers only the ones something is actually waiting on.
256
+
257
+ Anything else — A/B assignment, account personalisation, your own scripts — uses `window.vitopsConsent`:
258
+
259
+ ```js
260
+ window.vitopsConsent.subscribe((state) => {
261
+ if (window.vitopsConsent.granted('preferences')) restoreSavedLayout();
262
+ });
263
+ ```
264
+
265
+ `get()` · `granted(category)` · `needed()` · `set({ analytics: true })` · `acceptAll()` ·
266
+ `rejectAll()` · `reset()` · `open()` · `subscribe(fn)`. A `vitops:consent` event fires on `document`
267
+ at startup and on every change. Anything with `[data-consent-open]` reopens the banner, so a footer
268
+ "Cookie settings" link needs no JS of its own.
269
+
270
+ Behaviour worth knowing:
271
+
272
+ - **Nothing is stored until the visitor chooses.** No cookie, no localStorage. Showing the banner
273
+ can't be the thing that needs consent. An unreadable or wrong-version cookie re-prompts rather than
274
+ being read permissively.
275
+ - **Revoking clears cookies and reloads.** An already-executing tracker can't be unloaded any other
276
+ way. `<CookieConsent noReloadOnRevoke />` turns the reload off, at the cost of that tracker running
277
+ until the next navigation.
278
+ - **Rejecting is a decision** — the banner stays gone, it doesn't keep asking until told yes.
279
+ - **No geo detection.** The banner shows for everyone once enabled. Suppressing it from a timezone or
280
+ an IP guess fails toward _not asking_, which is the expensive direction to be wrong in.
281
+ - **With no JS, nothing happens and that's correct** — the gate never runs, so no gated tag loads and
282
+ no non-essential cookie is set. The banner stays hidden because there is nothing to consent to.
283
+ - **`<Analytics />` alone still uses the runtime** for scheduling, unless every provider is cookieless
284
+ _and_ `strategy: 'async'` — the one configuration that ships no consent JavaScript at all.
285
+
286
+ ### Keep it in step with your cookie notice
287
+
288
+ `vitops legal` derives the privacy policy and cookie notice from your **site config**, and
289
+ `getvitops({ analytics })` is a separate surface. Declare each provider in both:
290
+
291
+ ```jsonc
292
+ // site config — what the documents disclose
293
+ { "analytics": { "googleAnalyticsId": "G-XXXXXXXXXX", "clarityId": "abcd1234" } }
294
+ ```
295
+
296
+ Configure `legal` alongside `analytics` and the integration checks this for you, naming any provider
297
+ you'd otherwise be running without disclosing. It also warns when a cookie-setting provider is
298
+ configured with no `consent` gate.
299
+
64
300
  ## Components
65
301
 
66
302
  Thin authoring helpers that emit the framework's HTML/CSS patterns — **none require runtime JS**: