@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/CHANGELOG.md +694 -0
- package/README.md +239 -3
- package/dist/index.d.mts +622 -5
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +609 -12
- package/dist/index.mjs.map +1 -1
- package/package.json +20 -5
- package/src/analytics.ts +325 -0
- package/src/components/Analytics.astro +59 -0
- package/src/components/Cards.astro +1 -1
- 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 +49 -1
- package/src/components/Icon.astro +94 -0
- package/src/components/Popover.astro +9 -3
- package/src/components/Seo.astro +68 -0
- package/src/seo.ts +337 -0
- package/virtual.d.ts +59 -0
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
|
|
18
|
+
import vitops from '@getvitops/astro';
|
|
19
19
|
|
|
20
20
|
export default defineConfig({
|
|
21
21
|
integrations: [
|
|
22
|
-
|
|
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**:
|