@getvitops/astro 5.0.0 → 7.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 CHANGED
@@ -1,5 +1,234 @@
1
1
  # @getvitops/astro
2
2
 
3
+ ## 7.0.0
4
+
5
+ ### Major Changes
6
+
7
+ - aad43b7: A gallery/lightbox that is a fully working modal lightbox with **zero JavaScript**, plus
8
+ `ImageMetadata` support in `<Carousel />`.
9
+
10
+ Only `@getvitops/core`, `generator` and `astro` change here. This changeset makes **no
11
+ consumer-facing change** to `utils`, `cli` or `vite` — they still ship in this release because
12
+ the six share one version, but from a different changeset (`localbusiness-schema.md`) that
13
+ touches `utils` and `generator` for an unrelated reason.
14
+
15
+ **Breaking — `css`/`bricks`/`tailwind` (all formats):** the `.lightbox` thumbnail grid is now
16
+ `.gallery`, so it composes with the dialog it opens (`.lightbox-dialog`, unchanged) rather than
17
+ sharing its name. `vitops lint --fix` cannot cover this: `movedTokens()` renames colour token
18
+ names only, and there is no class-rename mechanism in `@getvitops/cli`. Migrate by hand:
19
+
20
+ | Old | New |
21
+ | ------------------ | ----------------- |
22
+ | `.lightbox` | `.gallery` |
23
+ | `.lightbox__item` | `.gallery__item` |
24
+ | `.lightbox__thumb` | `.gallery__thumb` |
25
+
26
+ `.lightbox-dialog` and its `__content` / `__image` / `__close` / `__nav` / `__nav-button` /
27
+ `__caption` parts are unchanged.
28
+
29
+ **Added — `css`/`bricks`/`tailwind` (all formats):** `patterns/gallery.css` — the `.gallery` grid,
30
+ now on the token cascade (`--br-gallery` etc.) it only half-had before.
31
+
32
+ **Added — `wc`:** `<wc-gallery>` (`@getvitops/core/elements`). Wraps a `.gallery` grid of
33
+ `command="show-modal"` triggers and the `.lightbox-dialog`s they open. With no JS, each trigger is
34
+ a real Invoker Commands button for its own dialog — open and close both work. `<wc-gallery>` adds
35
+ what the fallback deliberately cannot contain: prev/next, arrow keys, swipe (horizontal to step,
36
+ vertical to dismiss), an announced image counter, neighbour preload, and a thumbnail → full-image
37
+ `document.startViewTransition` morph where supported. Prev/next can't exist in the fallback — a
38
+ second `show-modal` invoker inside an already-open dialog stacks a second modal rather than
39
+ replacing the first — so it is generated by the element, never authored, the same rule
40
+ `<wc-tree>`'s search field follows.
41
+
42
+ **Added — `astro`:** `<Gallery />` (`@getvitops/astro/components/Gallery.astro`) — emits a
43
+ `.gallery` grid plus one `.lightbox-dialog` per image and its own `<wc-gallery>`; do not wrap it in
44
+ one. Images take `string | ImageMetadata`, rendered through a new internal `<ResponsiveImage />`
45
+ helper: a plain string renders the same raw `<img>` every component here used before, unchanged;
46
+ an `astro:assets` import renders through `<Picture />` for AVIF/WebP derivatives.
47
+
48
+ **Changed — `astro`:** `<Carousel />`'s `CarouselSlide.src` now also accepts `ImageMetadata`,
49
+ through the same `<ResponsiveImage />` helper, plus a new `formats` prop. Every existing
50
+ string-`src` call site renders byte-identical markup — purely additive.
51
+
52
+ **Added — `wc`:** the Invoker Commands API (`<button command commandfor>`) polyfill
53
+ (`invokers-polyfill`, feature-detected via `@getvitops/core/polyfills`) — resolves the TODO
54
+ `polyfills.ts` carried since 4.0. `<wc-gallery>`'s fallback and `<Drawer />` (which already emitted
55
+ `command="show-modal"` unconditionally) both now get real cross-browser coverage rather than
56
+ depending on it being "too new to rely on yet." Fetched only in an engine that doesn't support the
57
+ API natively, so a current browser downloads none of it.
58
+
59
+ **Migration:** rename `.lightbox` → `.gallery` and `.lightbox__item`/`.lightbox__thumb` →
60
+ `.gallery__item`/`.gallery__thumb` wherever authored (see table above); everything else is
61
+ additive. Consumers using the CSS pattern with no JS need no other change — the fallback markup is
62
+ unchanged in shape, only the class names moved.
63
+
64
+ ### Minor Changes
65
+
66
+ - **Added — `utils`.** `itemListGraph()`, a pure schema.org `ItemList` JSON-LD builder — a sibling
67
+ of `breadcrumbGraph()`/`localBusinessGraph()`. Extracted from `@getvitops/astro`'s `<Carousel />`
68
+ so any consumer can build a valid `ItemList` (Google's "all-in-one page" carousel format:
69
+ `ListItem.item` carrying `@type`/`name`/`url`) for content that isn't one of the six Google
70
+ carousel rich-result types (`Article`/`Recipe`/`Movie`/`Course`/`Restaurant`/`Product`) — the
71
+ entity type is no longer constrained to that set.
72
+
73
+ **Changed — `astro`.** `<Carousel />`'s `CarouselItem['type']` now accepts any string, not just
74
+ the six carousel rich-result types — non-breaking, the union only widens. It's a thin wrapper
75
+ over `itemListGraph()` now; behaviour for existing callers is unchanged.
76
+
77
+ No consumer-facing change in this release outside `utils` and `astro` — `generator`, `cli` and
78
+ `vite` still ship in this release only because the six share one version.
79
+
80
+ **Fixed — `core` (`css`/`bricks`/`tailwind` build output, all formats).** The CSS and HTML
81
+ authored inside Lit's `css`/`html` tagged templates in `<wc-*>` component sources previously
82
+ survived JS minification verbatim — a JS minifier treats a template literal's cooked text as
83
+ opaque, so `elements.js` shipped ~690 lines of indented, unminified CSS (and the equivalent in
84
+ HTML) inside an otherwise-minified 886-line file. Two source-level build transforms now collapse
85
+ both before bundling: `postcss` + `postcss-lit` + `cssnano` for `css` templates, `minify-html-literals`
86
+ (`conservativeCollapse: true`, so whitespace between inline elements in light-DOM components never
87
+ collapses to zero) for `html` templates. `elements.js` drops from 886 lines / ~101 KB to 49 lines /
88
+ ~96 KB. A second build step deduplicates the 17 `@license` comment blocks (only 6 are textually
89
+ distinct) down to one verbatim copy of each — required by BSD-3/MIT/Apache-2.0's notice-retention
90
+ terms either way, just no longer repeated once per source file that carries it. No API change;
91
+ build-time only, and none of the added tooling (`postcss`, `postcss-lit`, `cssnano`,
92
+ `minify-html-literals`) is a runtime or published dependency.
93
+
94
+ - 7b391b3: Add `<wc-counter>` — animates a number from a start value to the value already in its fallback
95
+ text, on intersection. The stat/percentage figure in a media tile ("0 → 94%"), a KPI row, a
96
+ metrics band.
97
+
98
+ No consumer-facing change in this release outside `core`, `generator` and `astro` — `utils`,
99
+ `cli` and `vite` still ship in this release only because the six share one version.
100
+
101
+ **Added — `css`/`bricks`/`tailwind` (all formats):** `.counter` / `.counter__value` /
102
+ `.counter__presentation` (`patterns/counter.css`). A registered `--_n` custom property drives the
103
+ count via a plain CSS `@keyframes` animation — the interpolation and the easing curve are CSS's,
104
+ never duplicated in JS.
105
+
106
+ **Added — `wc` (`@getvitops/core`).** `<wc-counter>` parses the final value out of its own
107
+ `.counter__value` fallback text — never from a separate attribute, so the no-JS reading and the
108
+ animated reading can't disagree — and renders it back through `Intl.NumberFormat`, seeded from the
109
+ fallback text's own separators (a grouped `1,284` never animates through ungrouped intermediates).
110
+ `counter()` cannot do that part: no decimals, no locale grouping, which is the one thing here that
111
+ genuinely needs JavaScript.
112
+ - Triggers on intersection via a single shared `IntersectionObserver`, not on upgrade — a counter
113
+ below the fold that finished before the visitor scrolled to it would have done nothing.
114
+ - `prefers-reduced-motion: reduce` means don't animate at all, checked before the observer is even
115
+ created, with `patterns/counter.css`'s own reduced-motion query as a second belt.
116
+ - The accessible name stays the final value for the whole animation: `.counter__value` is toggled
117
+ to the framework's `.sr-only` utility while running, never hidden — `display: none`/`visibility:
118
+ hidden` would pull it out of the accessible-name computation along with the visual hiding, and a
119
+ screen reader must never be read ~60 intermediate numbers. The animated text lives in a separate
120
+ `aria-hidden` presentation span it never reaches.
121
+ - `data-from` (default `0`), `data-duration` (ms, default `1200`), `data-easing` (`linear` |
122
+ `ease-in` | `ease-out` | `float-sine` | `float-bounce` — maps onto the real
123
+ `--custom-ease-*`/`--ease-float-*` tokens, so a curve edited in `animation.css` cannot drift out
124
+ of step with this element), `data-decimals` (inferred from the fallback text when absent).
125
+ - Never leaves an intermediate value on screen as the resting state: disconnecting mid-animation
126
+ restores the fallback immediately, and it was never mutated in the first place — only the
127
+ presentation span was ever written to.
128
+
129
+ **Added — `astro`.** `<Counter value="94%" />` emits its own `<wc-counter>` — do NOT wrap it in
130
+ one, same rule as `Gallery`/`Carousel`/`Tree`. `value` is the only source of the final figure;
131
+ there is no separate `to` prop, for the same one-source-of-truth reason the element itself parses
132
+ its fallback rather than taking a duplicate attribute. `enhance={false}` emits the bare fallback
133
+ for a page that cannot run `elements.js`.
134
+
135
+ ### Patch Changes
136
+
137
+ - 2b50f9e: Extract the LocalBusiness JSON-LD builder out of `<LocalBusiness />` into a reusable
138
+ `localBusinessGraph()`, and add the location fields listing platforms (GBP, Bing Places, Apple
139
+ Business Connect) actually need.
140
+
141
+ **Added — `@getvitops/utils`.** `localBusinessGraph(options)` (+ its `LocalBusinessGraphOptions`,
142
+ `OpeningHoursSpecification` and `SpecialHoursSpecification` types) is now exported from
143
+ `@getvitops/utils`, alongside the existing `organizationGraph`/`articleGraph`/`breadcrumbGraph`/
144
+ `faqGraph` builders — same module, same shape, usable anywhere a JSON-LD graph is built outside an
145
+ Astro component.
146
+
147
+ **Added — `authoring`/`config` (`@getvitops/generator`).** Four new `organization.locations.<slug>`
148
+ fields: `hoursSpecial` (dated deviations from the recurring `hours` — holiday hours, a one-off
149
+ closure), `photos` (`ImageRefSchema[]` → JSON-LD `image`), `sameAs` (this location's own listing
150
+ URLs, distinct from `organization.sameAs`, which is the company overall), and `listings` (external
151
+ listing ids keyed by platform — `google` | `bing` | `apple` — not consumed by the generator today;
152
+ recorded so a future listings-sync command has a stable id to match against).
153
+
154
+ **Changed — `astro`.** `<LocalBusiness />`'s `Props` type is now `LocalBusinessGraphOptions`
155
+ (re-exported, not redefined) rather than a local interface that had drifted from what the builder
156
+ actually accepted. No markup or prop-name change for an existing consumer.
157
+
158
+ - Updated dependencies [c0c092b]
159
+ - Updated dependencies
160
+ - Updated dependencies [aad43b7]
161
+ - Updated dependencies [2b50f9e]
162
+ - Updated dependencies [a6a8b39]
163
+ - Updated dependencies [7b391b3]
164
+ - Updated dependencies [eb9ac79]
165
+ - @getvitops/utils@7.0.0
166
+ - @getvitops/generator@7.0.0
167
+ - @getvitops/core@7.0.0
168
+ - @getvitops/vite@7.0.0
169
+
170
+ ## 6.0.0
171
+
172
+ ### Major Changes
173
+
174
+ - Carousel rework: a scroll strip that is operable and discoverable with no JS in every engine, and
175
+ real controls where the scroll-marker pseudo-elements are missing.
176
+
177
+ `::scroll-button()`, `::scroll-marker` and `::scroll-marker-group` are still Chromium-only, and
178
+ there is no usable polyfill (the one prototype targets the superseded syntax and only re-parses
179
+ inline `<style>` elements). So the carousel no longer assumes them: `<wc-carousel>` builds real
180
+ prev/next `<button>`s and a real dot nav where they are absent, styled from the same declarations
181
+ as the native ones. Which branch is live is decided **by the stylesheet** — one `@supports` sets
182
+ `--carousel-native-nav: 1` and the element reads it back — so the condition is written once and
183
+ the two cannot disagree.
184
+
185
+ **Breaking — `.carousel` is now a shell and `.carousel__track` is the scroller** (all formats). The
186
+ hint and the controls need a home outside the scrollport, and absolute positioning against the
187
+ shell replaces `position: fixed` + `anchor-name`/`position-area`, which pinned controls to a
188
+ viewport corner in any engine without anchor positioning. The 5.x single-element form is still
189
+ styled via `:not(:has(> .carousel__track))` and is promoted by the element at runtime, but it is
190
+ deprecated: wrap your slides in `<ul class="carousel__track">`.
191
+
192
+ **Breaking — looping is opt-in.** `<wc-carousel loop>` clones the slides; the default no longer
193
+ does. Cloning triples the markup, duplicates every image element and makes the scrollbar report 3×
194
+ the real content. `autoplay` implies `loop`. Migration: add `loop`.
195
+
196
+ **Breaking — `[part='autoplay']` is now `.carousel__autoplay`** (aliased for one release; `part`
197
+ only crosses a shadow boundary and this element is light DOM, so it was never a part). The button
198
+ is also a child of the shell rather than the track, where it used to occupy a slide-width column.
199
+
200
+ **Breaking — the host role is `group`, not `region`.** `region` is a landmark, so every carousel
201
+ grew an entry in the landmark list. Pass `landmark` to `<Carousel />` where it really is one.
202
+
203
+ **Changed (visual)** — the scrollbar is visible by default (`scrollbar-width: thin`, was `none`)
204
+ and a "Scroll for more" hint renders above the strip; both dim once the visitor scrolls, via a
205
+ scroll-progress timeline where available and a `data-scrolled` attribute otherwise. Opt out with
206
+ `.carousel--no-scrollbar` and `hint={false}`. `@getvitops/core`'s `global.css` also gains a narrow
207
+ media reset (`max-inline-size: 100%` on replaced elements, `block-size: auto` on `img`/`video`) —
208
+ deliberately without `display: block`, which would change inline images in prose.
209
+
210
+ **Added** — `@getvitops/astro/components/Carousel.astro`, a tier-3 wrapper emitting semantic
211
+ `<ul>`/`<li>`/`<figure>` markup with the right per-slide image priorities: the first slide eager
212
+ and high (the LCP candidate), the rest `lazy`/`low`/`async`. `loading="lazy"` is honoured for
213
+ images clipped by a horizontal scroll container, so off-screen slides defer with no JavaScript at
214
+ all. Also new: `carousel--markers-below` and `carousel--no-scrollbar`, and
215
+ `--carousel-slide-aspect`, which keeps slide heights equal whatever each image's aspect ratio is.
216
+
217
+ **Fixed** — `::scroll-button()` glyphs sat off-centre in their circles (`global.css`'s `*` reset
218
+ does not reach a pseudo-element, so the button was `content-box` with the UA's `padding: 1px 6px`
219
+ and `inline-size` + `aspect-ratio` sized an ellipse); the hover state was a no-op (it set the same
220
+ token as the base rule); slide heights varied with each image's aspect ratio; navigation used
221
+ physical `left`/`right` and so put "previous" on the wrong side in RTL; cloned slides duplicated
222
+ their `id`s; and `scrollBy(clientWidth)` overshot whenever `--carousel-slide-size` was not `100%`.
223
+
224
+ ### Patch Changes
225
+
226
+ - Updated dependencies
227
+ - @getvitops/core@6.0.0
228
+ - @getvitops/generator@6.0.0
229
+ - @getvitops/vite@6.0.0
230
+ - @getvitops/utils@6.0.0
231
+
3
232
  ## 5.0.0
4
233
 
5
234
  ### Major Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@getvitops/astro",
3
- "version": "5.0.0",
3
+ "version": "7.0.0",
4
4
  "description": "Astro integration for the Vitops design system — favicons/PWA, web-component scripts, and CSS, via a getvitops() integration + <Head /> component.",
5
5
  "license": "MIT",
6
6
  "files": [
@@ -16,6 +16,10 @@
16
16
  "src/ads.ts",
17
17
  "src/components/Subgrid.astro",
18
18
  "src/components/Cards.astro",
19
+ "src/components/Carousel.astro",
20
+ "src/components/Gallery.astro",
21
+ "src/components/ResponsiveImage.astro",
22
+ "src/components/Counter.astro",
19
23
  "src/components/NodeRenderer.astro",
20
24
  "src/components/WebComponentLoader.astro",
21
25
  "src/components/Icon.astro",
@@ -58,6 +62,9 @@
58
62
  "./Tracking.astro": "./src/components/Tracking.astro",
59
63
  "./components/Subgrid.astro": "./src/components/Subgrid.astro",
60
64
  "./components/Cards.astro": "./src/components/Cards.astro",
65
+ "./components/Carousel.astro": "./src/components/Carousel.astro",
66
+ "./components/Gallery.astro": "./src/components/Gallery.astro",
67
+ "./components/Counter.astro": "./src/components/Counter.astro",
61
68
  "./components/NodeRenderer.astro": "./src/components/NodeRenderer.astro",
62
69
  "./components/WebComponentLoader.astro": "./src/components/WebComponentLoader.astro",
63
70
  "./components/Icon.astro": "./src/components/Icon.astro",
@@ -75,10 +82,10 @@
75
82
  "access": "public"
76
83
  },
77
84
  "dependencies": {
78
- "@getvitops/core": "5.0.0",
79
- "@getvitops/generator": "5.0.0",
80
- "@getvitops/utils": "5.0.0",
81
- "@getvitops/vite": "5.0.0"
85
+ "@getvitops/core": "7.0.0",
86
+ "@getvitops/utils": "7.0.0",
87
+ "@getvitops/generator": "7.0.0",
88
+ "@getvitops/vite": "7.0.0"
82
89
  },
83
90
  "devDependencies": {
84
91
  "@astrojs/sitemap": "^3.7.3",
@@ -0,0 +1,241 @@
1
+ ---
2
+ /**
3
+ * A scroll-snap carousel that works with no JavaScript, in every engine.
4
+ *
5
+ * Tier-3 in the framework's terms — an authoring convenience that emits the right
6
+ * `.carousel` markup and requires **no runtime JS**. What it renders stands on its
7
+ * own:
8
+ *
9
+ * - the slides are a real `<ul>`/`<li>`, so assistive tech announces list
10
+ * position and count;
11
+ * - the scroller carries `tabindex="0"`, so arrow keys scroll it — the keyboard
12
+ * route the platform gives any focusable scroll container, and the reason
13
+ * inclusive-components.design/a-content-slider/ focuses the scroller rather
14
+ * than building a widget;
15
+ * - `scroll-snap` and (in Chromium today) `::scroll-button()` / `::scroll-marker`
16
+ * give buttons and dots with no script at all;
17
+ * - a visible scrollbar and a "Scroll for more" hint say the strip scrolls, which
18
+ * was the missing signal wherever those pseudo-elements are absent;
19
+ * - off-screen images are lazy **without JS**: `loading="lazy"` is honoured for
20
+ * images clipped by a horizontal scroll container, so there is no `data-src` +
21
+ * IntersectionObserver dance to fail.
22
+ *
23
+ * **This component emits its own `<wc-carousel>`** — do NOT wrap it in one. That is
24
+ * the tier-3 rule ("the wrapper emits its `<wc-*>` tag with the accessible fallback
25
+ * inside"), and `Cards.astro`, `Tree.astro` and `CookieConsent.astro` all do the
26
+ * same. The element defers to an enclosing one, so a nested pair degrades to a
27
+ * no-op rather than cloning the same slides twice, but the single call is the
28
+ * intended shape.
29
+ *
30
+ * `<wc-carousel>` adds the opt-in cloned loop, autoplay, and — only where the
31
+ * browser lacks the scroll-marker pseudo-elements — real prev/next buttons and a
32
+ * real dot nav. If it never loads, that enhancement is all that is missing, which
33
+ * is why `enhance={false}` is a supported way to get the bare fragment.
34
+ *
35
+ * Three deliberate a11y choices:
36
+ *
37
+ * - **The group role is on the shell, the list role stays on the `<ul>`.** The
38
+ * inclusive-components article puts `role="region"` on the scroller because
39
+ * there the scroller *is* the container; a shell lets us have real list
40
+ * semantics *and* a named group. `group` rather than `region` because `region`
41
+ * is a landmark and four carousels would grow four landmark entries — pass
42
+ * `landmark` when the carousel really is a page-level landmark.
43
+ * - **The visible hint is `aria-hidden`, and a separate `.sr-only` element is the
44
+ * description.** The hint fades once the visitor scrolls, so as a description
45
+ * it would go stale; and its text is about a pointer affordance, whereas what a
46
+ * keyboard user needs described is the arrow keys. One string cannot honestly
47
+ * be both.
48
+ * - **`aria-describedby` sits on the `<ul>`**, which is the focusable thing, since
49
+ * a description is announced on focus.
50
+ */
51
+ import type { HTMLAttributes } from 'astro/types';
52
+ import ResponsiveImage from './ResponsiveImage.astro';
53
+ import type { ImageFormat } from './ResponsiveImage.astro';
54
+
55
+ export interface CarouselSlide {
56
+ /** A plain string renders a raw `<img>`; an `astro:assets` import renders through
57
+ * `<Picture />` for AVIF/WebP derivatives — see `ResponsiveImage.astro`. */
58
+ src: string | ImageMetadata;
59
+ alt: string;
60
+ /**
61
+ * Intrinsic size. Emitted as `width`/`height` so the UA derives an aspect ratio
62
+ * and the slide reserves space before the image lands. When unknown,
63
+ * `--carousel-slide-aspect` still reserves the box, so CLS is covered either way.
64
+ * Only meaningful for a plain-string `src` — an `ImageMetadata` source's natural
65
+ * size is already known.
66
+ */
67
+ width?: number;
68
+ height?: number;
69
+ /** Only meaningful for a plain-string `src` — see `ResponsiveImage.astro`. */
70
+ srcset?: string;
71
+ sizes?: string;
72
+ caption?: string;
73
+ }
74
+
75
+ export interface Props extends Omit<HTMLAttributes<'div'>, 'slot'> {
76
+ /** Accessible name. Required — an unnamed group is unfindable. */
77
+ label: string;
78
+ /** Image slides. Omit and use the default slot for richer slides. */
79
+ slides?: CarouselSlide[];
80
+ /** Passed to every `<Picture formats>` for an `ImageMetadata` slide. Default `['avif', 'webp']`. */
81
+ formats?: ImageFormat[];
82
+ /** Milliseconds between slides. Implies `loop`. */
83
+ autoplay?: number;
84
+ /**
85
+ * Clone the slides for a seamless infinite strip. **Off by default:** it triples
86
+ * the DOM and duplicates every image element, makes the scrollbar report 3× the
87
+ * real content, and most carousels do not want it.
88
+ */
89
+ loop?: boolean;
90
+ /** Prev/next controls. */
91
+ buttons?: boolean;
92
+ /** Dot navigation. */
93
+ markers?: boolean;
94
+ /** Dots below the strip rather than overlaid on it. */
95
+ markersBelow?: boolean;
96
+ /** CSS multicol paging. Chromium-only; degrades to a plain strip elsewhere. */
97
+ columns?: number;
98
+ /** Dim and inert the off-screen slides. */
99
+ inert?: boolean;
100
+ /** `scroll-snap-stop: always` — never skip a slide in one fling. */
101
+ forceStop?: boolean;
102
+ /** Hide the scrollbar. Costs the no-JS "this scrolls" signal; be deliberate. */
103
+ noScrollbar?: boolean;
104
+ /** `--carousel-slide-aspect`, the ratio every slide's media box takes. */
105
+ aspect?: string;
106
+ /** Visible affordance text. `false` removes it. */
107
+ hint?: string | false;
108
+ /** Expose as a landmark (`role="region"`) rather than `role="group"`. */
109
+ landmark?: boolean;
110
+ /**
111
+ * Emit the `<wc-carousel>` wrapper (default `true`).
112
+ *
113
+ * Set false where the element cannot run — an email, a PDF, a page that does not
114
+ * load `elements.js`. The carousel is unchanged; only the enhancement layer goes,
115
+ * so nothing is lost but the loop, autoplay and the non-Chromium control set.
116
+ */
117
+ enhance?: boolean;
118
+ /**
119
+ * Id prefix for the slide ids and the description. Defaults to a slug of
120
+ * `label`. Astro has no `useId`, and a random one would change on every build —
121
+ * so this is derived, deterministic and overridable when two carousels on a page
122
+ * share a label.
123
+ */
124
+ id?: string;
125
+ }
126
+
127
+ const {
128
+ label,
129
+ slides = [],
130
+ formats = ['avif', 'webp'],
131
+ autoplay,
132
+ loop = false,
133
+ buttons = true,
134
+ markers = true,
135
+ markersBelow = false,
136
+ columns,
137
+ inert = false,
138
+ forceStop = false,
139
+ noScrollbar = false,
140
+ aspect = '16 / 9',
141
+ hint = 'Scroll for more',
142
+ landmark = false,
143
+ enhance = true,
144
+ id,
145
+ class: className,
146
+ role,
147
+ style,
148
+ ...rest
149
+ } = Astro.props;
150
+
151
+ const uid =
152
+ id ??
153
+ `carousel-${
154
+ label
155
+ .toLowerCase()
156
+ .replace(/[^a-z0-9]+/g, '-')
157
+ .replace(/^-|-$/g, '') || 'x'
158
+ }`;
159
+
160
+ const classes = [
161
+ 'carousel',
162
+ buttons && 'carousel--scroll-buttons',
163
+ markers && 'carousel--scroll-markers',
164
+ markersBelow && 'carousel--markers-below',
165
+ columns && 'carousel--auto-pages',
166
+ inert && 'carousel--inert',
167
+ forceStop && 'carousel--force-stop',
168
+ noScrollbar && 'carousel--no-scrollbar',
169
+ className,
170
+ ];
171
+
172
+ const vars = [
173
+ `--carousel-slide-aspect:${aspect}`,
174
+ columns ? `--carousel-columns:${columns}` : '',
175
+ style ?? '',
176
+ ]
177
+ .filter(Boolean)
178
+ .join(';');
179
+
180
+ const Shell = enhance ? 'wc-carousel' : 'div';
181
+ ---
182
+
183
+ <Shell
184
+ class:list={classes}
185
+ role={role ?? (landmark ? 'region' : 'group')}
186
+ aria-roledescription="carousel"
187
+ aria-label={label}
188
+ loop={enhance && (loop || (autoplay ?? 0) > 0) ? '' : undefined}
189
+ autoplay={enhance && autoplay ? autoplay : undefined}
190
+ style={vars}
191
+ {...rest}
192
+ >
193
+ <ul
194
+ class="carousel__track"
195
+ role="list"
196
+ tabindex="0"
197
+ aria-label={label}
198
+ aria-describedby={`${uid}-desc`}
199
+ >
200
+ {
201
+ slides.map((slide, i) => (
202
+ <li class="carousel__slide" id={`${uid}-slide-${i + 1}`}>
203
+ <figure class="carousel__media">
204
+ {/* The first slide is the LCP candidate, so it is eager and high
205
+ priority; every later one is lazy, low and async. A horizontal
206
+ scroll container clips them, and `loading="lazy"` is honoured for
207
+ that, so this is genuinely JS-free deferral. `decoding` is left
208
+ unset on the first: `sync` can block the main thread, and the UA's
209
+ own choice for an eager LCP image is better than ours. */}
210
+ <ResponsiveImage
211
+ src={slide.src}
212
+ alt={slide.alt}
213
+ formats={formats}
214
+ srcset={slide.srcset}
215
+ sizes={slide.sizes}
216
+ width={slide.width}
217
+ height={slide.height}
218
+ loading={i === 0 ? 'eager' : 'lazy'}
219
+ fetchpriority={i === 0 ? 'high' : 'low'}
220
+ decoding={i === 0 ? undefined : 'async'}
221
+ />
222
+ {slide.caption && (
223
+ <figcaption class="carousel__caption font-caption">{slide.caption}</figcaption>
224
+ )}
225
+ </figure>
226
+ </li>
227
+ ))
228
+ }
229
+ <slot />
230
+ </ul>
231
+ {
232
+ hint !== false && (
233
+ <p class="carousel__hint font-footnote" aria-hidden="true">
234
+ {hint}
235
+ </p>
236
+ )
237
+ }
238
+ <p id={`${uid}-desc`} class="sr-only">
239
+ Use the left and right arrow keys, or scroll sideways, to move between slides.
240
+ </p>
241
+ </Shell>
@@ -0,0 +1,80 @@
1
+ ---
2
+ /**
3
+ * A `.counter` figure that counts up from a start value to `value` once it
4
+ * scrolls into view — the stat/percentage figure in a media tile ("0 → 94%"),
5
+ * a KPI row, a metrics band.
6
+ *
7
+ * Tier-3 in the framework's terms — an authoring convenience that emits the
8
+ * right `.counter` markup and needs no runtime JS to be usable: with no JS,
9
+ * `value` is simply the number on the page, in plain semantic text. Nothing is
10
+ * missing, there is only no count-up.
11
+ *
12
+ * **This component emits its own `<wc-counter>`** — do NOT wrap it in one.
13
+ * That is the tier-3 rule ("the wrapper emits its `<wc-*>` tag with the
14
+ * accessible fallback inside"), and `Cards.astro`, `Carousel.astro`,
15
+ * `Tree.astro` and `Gallery.astro` all do the same. The element defers to an
16
+ * enclosing one, so a nested pair degrades to a no-op rather than
17
+ * double-enhancing, but the single call is the intended shape.
18
+ *
19
+ * `<wc-counter>` adds the count-up itself, triggered once the figure scrolls
20
+ * into view and skipped entirely under `prefers-reduced-motion: reduce`. If it
21
+ * never loads, that animation is all that's missing — set `enhance={false}`
22
+ * for the bare fragment (an email, a PDF, a page that doesn't load
23
+ * `elements.js`).
24
+ *
25
+ * `value` is the ONLY source of the final figure — there is no separate `to`
26
+ * prop. `<wc-counter>` parses it back out of the rendered text at runtime, so
27
+ * this component cannot render one number while claiming to animate to a
28
+ * different one.
29
+ */
30
+ import type { HTMLAttributes } from 'astro/types';
31
+
32
+ export interface Props extends Omit<HTMLAttributes<'span'>, 'slot'> {
33
+ /** The finished figure, e.g. `"94%"`, `"$1,234.50"`, `"1,284×"`. */
34
+ value: string | number;
35
+ /** Starting value the animation counts from. Default `0`. */
36
+ from?: number;
37
+ /** Animation duration in ms. Default `1200`. */
38
+ duration?: number;
39
+ /**
40
+ * A named curve from the framework's animation tokens — `--custom-ease-in`,
41
+ * `--custom-ease-out`, `--ease-float-sine`, `--ease-float-bounce` — or
42
+ * `"linear"`. Default `--custom-ease-out`. An unrecognised name is ignored.
43
+ */
44
+ easing?: 'linear' | 'ease-in' | 'ease-out' | 'float-sine' | 'float-bounce';
45
+ /** Decimal places to render. Inferred from `value`'s own text when absent. */
46
+ decimals?: number;
47
+ /**
48
+ * Emit the `<wc-counter>` wrapper (default `true`).
49
+ *
50
+ * Set false where the element cannot run — an email, a PDF, a page that
51
+ * does not load `elements.js`. `value` is unchanged; only the count-up
52
+ * animation goes.
53
+ */
54
+ enhance?: boolean;
55
+ }
56
+
57
+ const {
58
+ value,
59
+ from,
60
+ duration,
61
+ easing,
62
+ decimals,
63
+ enhance = true,
64
+ class: className,
65
+ ...rest
66
+ } = Astro.props;
67
+
68
+ const Shell = enhance ? 'wc-counter' : 'span';
69
+ ---
70
+
71
+ <Shell
72
+ class:list={['counter', className]}
73
+ data-from={enhance ? from : undefined}
74
+ data-duration={enhance ? duration : undefined}
75
+ data-easing={enhance ? easing : undefined}
76
+ data-decimals={enhance ? decimals : undefined}
77
+ {...rest}
78
+ >
79
+ <span class="counter__value">{value}</span>
80
+ </Shell>
@@ -0,0 +1,210 @@
1
+ ---
2
+ /**
3
+ * A `.gallery` thumbnail grid whose triggers open per-image `.lightbox-dialog`s —
4
+ * a fully working modal lightbox with zero JavaScript.
5
+ *
6
+ * Tier-3 in the framework's terms — an authoring convenience that emits the right
7
+ * `.gallery` / `.lightbox-dialog` markup and needs no runtime JS to be usable:
8
+ *
9
+ * - each thumbnail is a real `command="show-modal" commandfor="…"` invoker
10
+ * button for its own `<dialog>`, so open and close both work with the
11
+ * Invoker Commands API alone (universal via `@getvitops/core/polyfills`);
12
+ * - the dialogs are real `<dialog closedby="any">`, so Escape and a backdrop
13
+ * click close them with no script either;
14
+ * - what's missing without JS is prev/next — deliberately, because a
15
+ * `command="show-modal"` button inside an already-open dialog would stack a
16
+ * second modal on top rather than replace the first. That control is
17
+ * generated by `<wc-gallery>`, never authored, the same rule `<wc-tree>`'s
18
+ * search field follows: a control that does nothing is worse than none.
19
+ *
20
+ * **This component emits its own `<wc-gallery>`** — do NOT wrap it in one. That
21
+ * is the tier-3 rule ("the wrapper emits its `<wc-*>` tag with the accessible
22
+ * fallback inside"), and `Cards.astro`, `Carousel.astro` and `Tree.astro` all do
23
+ * the same. The element defers to an enclosing one, so a nested pair degrades to
24
+ * a no-op rather than double-enhancing, but the single call is the intended shape.
25
+ *
26
+ * `<wc-gallery>` adds prev/next, arrow keys, swipe, an image counter, neighbour
27
+ * preload and a thumbnail → full-image view-transition morph. If it never loads,
28
+ * that enhancement is all that's missing — set `enhance={false}` for the bare
29
+ * fragment (an email, a PDF, a page that doesn't load `elements.js`).
30
+ *
31
+ * Images take `string | ImageMetadata` via `<ResponsiveImage />`: a plain string
32
+ * renders a raw `<img>` (unchanged from before `ImageMetadata` support existed);
33
+ * an `astro:assets` import renders through `<Picture />` for AVIF/WebP
34
+ * derivatives. `formats` is passed straight through, including a value
35
+ * astro:assets doesn't recognise yet — see `ResponsiveImage.astro`'s docblock for
36
+ * why (no hard-coded closed format union).
37
+ */
38
+ import type { HTMLAttributes } from 'astro/types';
39
+ import ResponsiveImage from './ResponsiveImage.astro';
40
+ import type { ImageFormat } from './ResponsiveImage.astro';
41
+
42
+ export interface GalleryImage {
43
+ /** Full-size image, shown in the dialog. */
44
+ src: string | ImageMetadata;
45
+ /** Thumbnail. Defaults to `src` — for an `ImageMetadata` source, rendered at `thumbWidth`. */
46
+ thumb?: string | ImageMetadata;
47
+ /** Pixel width to request for the thumbnail derivative when `src`/`thumb` is `ImageMetadata`. */
48
+ thumbWidth?: number;
49
+ alt: string;
50
+ caption?: string;
51
+ width?: number;
52
+ height?: number;
53
+ /** Only meaningful when `src` is a plain string — see `ResponsiveImage.astro`. */
54
+ srcset?: string;
55
+ sizes?: string;
56
+ }
57
+
58
+ export interface Props extends Omit<HTMLAttributes<'div'>, 'slot'> {
59
+ /** Accessible name for the grid. Required — an unnamed group is unfindable. */
60
+ label: string;
61
+ images?: GalleryImage[];
62
+ /** Passed to every `<Picture formats>`. Default `['avif', 'webp']`. */
63
+ formats?: ImageFormat[];
64
+ /** `--gallery-thumb-size` — minimum thumbnail track width. Default `150px`. */
65
+ thumbSize?: string;
66
+ /** `--gallery-gap`. Default `0.5rem`. */
67
+ gap?: string;
68
+ /** `--gallery-aspect` — every thumbnail's box ratio. Default `1`. */
69
+ aspect?: string;
70
+ /** Wrap prev/next past the ends instead of stopping there. */
71
+ loop?: boolean;
72
+ /**
73
+ * Emit the `<wc-gallery>` wrapper (default `true`).
74
+ *
75
+ * Set false where the element cannot run — an email, a PDF, a page that does
76
+ * not load `elements.js`. The gallery is unchanged; only the enhancement
77
+ * layer goes, so nothing is lost but prev/next, swipe, the counter and the
78
+ * view-transition morph.
79
+ */
80
+ enhance?: boolean;
81
+ /**
82
+ * Id prefix for the dialog ids. Defaults to a slug of `label`. Astro has no
83
+ * `useId`, and a random one would change on every build — so this is derived,
84
+ * deterministic and overridable when two galleries on a page share a label.
85
+ */
86
+ id?: string;
87
+ }
88
+
89
+ const {
90
+ label,
91
+ images = [],
92
+ formats = ['avif', 'webp'],
93
+ thumbSize,
94
+ gap,
95
+ aspect,
96
+ loop = false,
97
+ enhance = true,
98
+ id,
99
+ class: className,
100
+ role,
101
+ style,
102
+ ...rest
103
+ } = Astro.props;
104
+
105
+ const uid =
106
+ id ??
107
+ `gallery-${
108
+ label
109
+ .toLowerCase()
110
+ .replace(/[^a-z0-9]+/g, '-')
111
+ .replace(/^-|-$/g, '') || 'x'
112
+ }`;
113
+
114
+ const vars = [
115
+ thumbSize ? `--gallery-thumb-size:${thumbSize}` : '',
116
+ gap ? `--gallery-gap:${gap}` : '',
117
+ aspect ? `--gallery-aspect:${aspect}` : '',
118
+ style ?? '',
119
+ ]
120
+ .filter(Boolean)
121
+ .join(';');
122
+
123
+ const Shell = enhance ? 'wc-gallery' : 'div';
124
+ ---
125
+
126
+ <Shell
127
+ class:list={['gallery-shell', className]}
128
+ role={role ?? 'group'}
129
+ aria-label={label}
130
+ loop={enhance && loop ? '' : undefined}
131
+ style={vars}
132
+ {...rest}
133
+ >
134
+ <ul class="gallery" role="list">
135
+ {
136
+ images.map((img, i) => {
137
+ const thumb = img.thumb ?? img.src;
138
+ return (
139
+ <li class="gallery__item">
140
+ <button
141
+ class="gallery__trigger"
142
+ type="button"
143
+ command="show-modal"
144
+ commandfor={`${uid}-img-${i}`}
145
+ >
146
+ <ResponsiveImage
147
+ src={thumb}
148
+ alt={img.alt}
149
+ formats={formats}
150
+ class="gallery__thumb"
151
+ width={typeof thumb === 'string' ? undefined : (img.thumbWidth ?? 400)}
152
+ loading={i === 0 ? 'eager' : 'lazy'}
153
+ fetchpriority={i === 0 ? 'high' : 'low'}
154
+ decoding={i === 0 ? undefined : 'async'}
155
+ />
156
+ <span class="visually-hidden">View full size</span>
157
+ </button>
158
+ </li>
159
+ );
160
+ })
161
+ }
162
+ <slot />
163
+ </ul>
164
+ {
165
+ images.map((img, i) => (
166
+ <dialog id={`${uid}-img-${i}`} class="lightbox-dialog" closedby="any" aria-label={img.alt}>
167
+ <div class="lightbox-dialog__content">
168
+ <ResponsiveImage
169
+ src={img.src}
170
+ alt={img.alt}
171
+ formats={formats}
172
+ class="lightbox-dialog__image"
173
+ width={img.width}
174
+ height={img.height}
175
+ srcset={img.srcset}
176
+ sizes={img.sizes}
177
+ loading="lazy"
178
+ />
179
+ {img.caption && <p class="lightbox-dialog__caption font-caption">{img.caption}</p>}
180
+ </div>
181
+ <button
182
+ class="lightbox-dialog__close"
183
+ type="button"
184
+ command="close"
185
+ commandfor={`${uid}-img-${i}`}
186
+ aria-label="Close"
187
+ >
188
+ &times;
189
+ </button>
190
+ </dialog>
191
+ ))
192
+ }
193
+ </Shell>
194
+
195
+ <style>
196
+ /* Icon-only trigger keeps its label for screen readers. Component-scoped
197
+ rather than the framework's `sr-only`: that one is in the generator's
198
+ TW_CLASH list, so the tailwind format ships no rule for it. */
199
+ .visually-hidden {
200
+ position: absolute;
201
+ inline-size: 1px;
202
+ block-size: 1px;
203
+ padding: 0;
204
+ margin: -1px;
205
+ overflow: hidden;
206
+ clip: rect(0, 0, 0, 0);
207
+ white-space: nowrap;
208
+ border: 0;
209
+ }
210
+ </style>
@@ -0,0 +1,52 @@
1
+ ---
2
+ /**
3
+ * Internal helper — turns `string | ImageMetadata` into markup, so `Gallery` and
4
+ * `Carousel` render images identically and cannot drift from each other.
5
+ *
6
+ * Not exported from `package.json`: it is packaging detail, not a pattern a
7
+ * consumer places on a page (same treatment as `TreeNode.astro`), so it carries
8
+ * no `TIERS` entry either — `tiers.test.ts` only scans `exports`.
9
+ *
10
+ * A plain `string` renders the raw `<img>` path every Astro component in this
11
+ * package already used before `ImageMetadata` support existed — unchanged, so
12
+ * every existing string-`src` call site keeps rendering byte-identical markup.
13
+ * An `ImageMetadata` (an `astro:assets` image import) renders through
14
+ * `<Picture />`, which is what gets a consumer AVIF/WebP derivatives instead of
15
+ * shipping whatever format the source file happens to be in.
16
+ *
17
+ * `formats` defaults to `['avif', 'webp']` and is passed straight through to
18
+ * `<Picture formats>` — including a value astro:assets doesn't recognise yet.
19
+ * That is deliberate: Astro's `VALID_OUTPUT_FORMATS` today is
20
+ * `avif/png/webp/jpeg/jpg/svg` (no JPEG-XL — the sharp service can't emit it), so
21
+ * hard-coding a closed format union here would need editing the day a service
22
+ * adds one. `ImageFormat` mirrors Astro's own `ImageOutputFormat`
23
+ * (`… | (string & {})`) for exactly that reason, defined locally rather than
24
+ * imported — `astro:assets`'s ambient module declaration doesn't export it, and
25
+ * reaching into `astro/dist/**` for a type is not a stable import.
26
+ */
27
+ import { Picture } from 'astro:assets';
28
+ import type { HTMLAttributes } from 'astro/types';
29
+
30
+ export type ImageFormat = 'avif' | 'webp' | 'png' | 'jpeg' | 'jpg' | 'gif' | 'svg' | (string & {});
31
+
32
+ export interface Props extends Omit<HTMLAttributes<'img'>, 'src' | 'alt'> {
33
+ src: string | ImageMetadata;
34
+ alt: string;
35
+ formats?: ImageFormat[];
36
+ /**
37
+ * Only meaningful when `src` is a plain `string` — Astro computes its own
38
+ * `srcset` for an `ImageMetadata` source and this is not merged with it.
39
+ */
40
+ srcset?: string;
41
+ }
42
+
43
+ const { src, alt, formats = ['avif', 'webp'], srcset, class: className, ...rest } = Astro.props;
44
+ ---
45
+
46
+ {
47
+ typeof src === 'string' ? (
48
+ <img src={src} alt={alt} srcset={srcset} class={className} {...rest} />
49
+ ) : (
50
+ <Picture src={src} alt={alt} formats={formats} class={className} {...rest} />
51
+ )
52
+ }
@@ -10,9 +10,12 @@
10
10
  * position and depth. A `<div>` soup looks identical and tells a screen-reader
11
11
  * user nothing about where they are;
12
12
  * - every branch is a native `<details>`/`<summary>`, so expanded state and
13
- * keyboard operation come from the platform. No `role="tree"`, no
14
- * `aria-expanded` bookkeeping — a hand-rolled ARIA tree that loses its JS is
15
- * worse than no ARIA at all;
13
+ * keyboard operation come from the platform with no JS at all. This
14
+ * fallback carries no `role="tree"` and no `aria-expanded` bookkeeping —
15
+ * those aren't hand-rolled into markup that would be silently wrong the
16
+ * instant JS failed to load; `<wc-tree>` applies them on upgrade, over a
17
+ * DOM it unwraps for the purpose (branches lose their `<details>`/
18
+ * `<summary>` entirely — see that element's own docblock for why and how);
16
19
  * - nodes carry ids, so any node is linkable without JS.
17
20
  *
18
21
  * **This component emits its own `<wc-tree>`** — do NOT wrap it in one. That is the
@@ -22,9 +25,11 @@
22
25
  * element now defers to an enclosing one, so it degrades to a no-op rather than two
23
26
  * duelling toolbars, but the single call is the intended shape.
24
27
  *
25
- * `<wc-tree>` upgrades it with filter, expand/collapse-all and deep-linking. If
26
- * that never loads, only those three conveniences are missing — which is why
27
- * `enhance={false}` is a supported way to get the bare fragment.
28
+ * `<wc-tree>` upgrades it with WAI-ARIA tree semantics, roving-tabindex keyboard
29
+ * navigation, filter, expand/collapse-all and hash deep-linking. If that never
30
+ * loads, the fallback above — native nested `<details>`, Tab-and-Enter — is all
31
+ * that remains, which is why `enhance={false}` is a supported way to get the
32
+ * bare fragment on its own.
28
33
  */
29
34
  import type { TreeItem } from './tree.ts';
30
35
  import TreeNode from './TreeNode.astro';
@@ -6,13 +6,23 @@
6
6
  *
7
7
  * - a **branch** wraps its label in `<details>`/`<summary>` — a native
8
8
  * disclosure button, so expanded state is announced and keyboard operation
9
- * is free. The child `<ul>` lives inside the `<details>`, which is what makes
10
- * it collapse with no JS.
9
+ * is free with no JS. The child `<ul>` lives inside the `<details>`, which
10
+ * is what makes it collapse with no JS.
11
11
  * - a **leaf** is a plain `.tree__content` row. A `<details>` with nothing to
12
12
  * disclose would still render a control and announce as collapsible.
13
13
  *
14
+ * That description is the NO-JS contract only. `<wc-tree>` unwraps every
15
+ * branch's `<details>`/`<summary>` on upgrade — see its own docblock — so once
16
+ * it loads, "keyboard operation is free" stops meaning native Tab-and-Enter and
17
+ * starts meaning that element's roving-tabindex arrow-key navigation instead,
18
+ * and `role="treeitem"`/`role="group"` replace the `<li>`'s and `<ul>`'s
19
+ * implicit `listitem`/`list` roles. The markup below never changes to reflect
20
+ * that — it's the fallback `<wc-tree>` reads and transforms, not two things to
21
+ * keep in sync.
22
+ *
14
23
  * The item class sits on the `<li>` in both cases, so `<wc-tree>`'s filter treats
15
- * leaves and branches as one kind of thing and hiding one hides its whole row.
24
+ * leaves and branches as one kind of thing and hiding one hides its whole row —
25
+ * true before and after upgrade, since the transform preserves the class.
16
26
  */
17
27
  import type { TreeItem } from './tree.ts';
18
28
 
@@ -1,33 +1,12 @@
1
1
  ---
2
- export interface CarouselItem {
3
- type: 'Article' | 'Recipe' | 'Movie' | 'Course' | 'Restaurant' | 'Product';
4
- name: string;
5
- url: string;
6
- image?: string;
7
- description?: string;
8
- }
2
+ import { itemListGraph } from '@getvitops/utils';
3
+ import type { ItemListEntry, ItemListGraphOptions } from '@getvitops/utils';
9
4
 
10
- export interface Props {
11
- items: CarouselItem[];
12
- }
5
+ export type { ItemListEntry };
6
+ export type CarouselItem = ItemListEntry;
7
+ export type Props = ItemListGraphOptions;
13
8
 
14
- const { items } = Astro.props;
15
-
16
- const jsonLd = {
17
- '@context': 'https://schema.org',
18
- '@type': 'ItemList',
19
- itemListElement: items.map((item, index) => ({
20
- '@type': 'ListItem',
21
- position: index + 1,
22
- item: {
23
- '@type': item.type,
24
- name: item.name,
25
- url: item.url,
26
- ...(item.image && { image: item.image }),
27
- ...(item.description && { description: item.description }),
28
- },
29
- })),
30
- };
9
+ const jsonLd = itemListGraph(Astro.props);
31
10
  ---
32
11
 
33
12
  <script is:inline type="application/ld+json" set:html={JSON.stringify(jsonLd)} />
@@ -1,154 +1,10 @@
1
1
  ---
2
- export interface OpeningHoursSpecification {
3
- dayOfWeek: ('Monday' | 'Tuesday' | 'Wednesday' | 'Thursday' | 'Friday' | 'Saturday' | 'Sunday')[];
4
- opens: string;
5
- closes: string;
6
- validFrom?: string;
7
- validThrough?: string;
8
- }
2
+ import { localBusinessGraph } from '@getvitops/utils';
3
+ import type { LocalBusinessGraphOptions } from '@getvitops/utils';
9
4
 
10
- export interface Props {
11
- type?: 'LocalBusiness' | 'Restaurant' | 'Store' | 'MedicalBusiness' | 'FinancialService' | 'FoodEstablishment' | 'HealthAndBeautyBusiness' | 'HomeAndConstructionBusiness' | 'LegalService' | 'LodgingBusiness' | 'ProfessionalService' | 'SportsActivityLocation';
12
- name: string;
13
- description?: string;
14
- url?: string;
15
- telephone?: string;
16
- email?: string;
17
- image?: string | string[];
18
- priceRange?: string;
19
- address: {
20
- streetAddress: string;
21
- addressLocality: string;
22
- addressRegion?: string;
23
- postalCode: string;
24
- addressCountry: string;
25
- };
26
- geo?: {
27
- latitude: number;
28
- longitude: number;
29
- };
30
- openingHoursSpecification?: OpeningHoursSpecification[];
31
- aggregateRating?: {
32
- ratingValue: number;
33
- reviewCount?: number;
34
- ratingCount?: number;
35
- bestRating?: number;
36
- worstRating?: number;
37
- };
38
- review?: {
39
- author: string;
40
- datePublished: string;
41
- reviewBody: string;
42
- reviewRating?: {
43
- ratingValue: number;
44
- bestRating?: number;
45
- worstRating?: number;
46
- };
47
- }[];
48
- servesCuisine?: string[];
49
- menu?: string;
50
- acceptsReservations?: boolean;
51
- hasMenu?: string;
52
- paymentAccepted?: string[];
53
- currenciesAccepted?: string;
54
- areaServed?: string[];
55
- sameAs?: string[];
56
- }
5
+ export type Props = LocalBusinessGraphOptions;
57
6
 
58
- const {
59
- type = 'LocalBusiness',
60
- name,
61
- description,
62
- url,
63
- telephone,
64
- email,
65
- image,
66
- priceRange,
67
- address,
68
- geo,
69
- openingHoursSpecification,
70
- aggregateRating,
71
- review,
72
- servesCuisine,
73
- menu,
74
- acceptsReservations,
75
- hasMenu,
76
- paymentAccepted,
77
- currenciesAccepted,
78
- areaServed,
79
- sameAs,
80
- } = Astro.props;
81
-
82
- const jsonLd = {
83
- '@context': 'https://schema.org',
84
- '@type': type,
85
- name,
86
- ...(description && { description }),
87
- ...(url && { url }),
88
- ...(telephone && { telephone }),
89
- ...(email && { email }),
90
- ...(image && { image: Array.isArray(image) ? image : [image] }),
91
- ...(priceRange && { priceRange }),
92
- address: {
93
- '@type': 'PostalAddress',
94
- streetAddress: address.streetAddress,
95
- addressLocality: address.addressLocality,
96
- ...(address.addressRegion && { addressRegion: address.addressRegion }),
97
- postalCode: address.postalCode,
98
- addressCountry: address.addressCountry,
99
- },
100
- ...(geo?.latitude != null && geo?.longitude != null && {
101
- geo: {
102
- '@type': 'GeoCoordinates',
103
- latitude: geo.latitude,
104
- longitude: geo.longitude,
105
- },
106
- }),
107
- ...(openingHoursSpecification && {
108
- openingHoursSpecification: openingHoursSpecification.map(spec => ({
109
- '@type': 'OpeningHoursSpecification',
110
- dayOfWeek: spec.dayOfWeek,
111
- opens: spec.opens,
112
- closes: spec.closes,
113
- ...(spec.validFrom && { validFrom: spec.validFrom }),
114
- ...(spec.validThrough && { validThrough: spec.validThrough }),
115
- })),
116
- }),
117
- ...(aggregateRating && {
118
- aggregateRating: {
119
- '@type': 'AggregateRating',
120
- ratingValue: aggregateRating.ratingValue,
121
- ...(aggregateRating.reviewCount && { reviewCount: aggregateRating.reviewCount }),
122
- ...(aggregateRating.ratingCount && { ratingCount: aggregateRating.ratingCount }),
123
- bestRating: aggregateRating.bestRating ?? 5,
124
- worstRating: aggregateRating.worstRating ?? 1,
125
- },
126
- }),
127
- ...(review && {
128
- review: review.map(r => ({
129
- '@type': 'Review',
130
- author: { '@type': 'Person', name: r.author },
131
- datePublished: r.datePublished,
132
- reviewBody: r.reviewBody,
133
- ...(r.reviewRating && {
134
- reviewRating: {
135
- '@type': 'Rating',
136
- ratingValue: r.reviewRating.ratingValue,
137
- bestRating: r.reviewRating.bestRating ?? 5,
138
- worstRating: r.reviewRating.worstRating ?? 1,
139
- },
140
- }),
141
- })),
142
- }),
143
- ...(servesCuisine && { servesCuisine }),
144
- ...(menu && { menu }),
145
- ...(acceptsReservations !== undefined && { acceptsReservations }),
146
- ...(hasMenu && { hasMenu }),
147
- ...(paymentAccepted && { paymentAccepted }),
148
- ...(currenciesAccepted && { currenciesAccepted }),
149
- ...(areaServed && { areaServed }),
150
- ...(sameAs && { sameAs }),
151
- };
7
+ const jsonLd = localBusinessGraph(Astro.props);
152
8
  ---
153
9
 
154
10
  <script is:inline type="application/ld+json" set:html={JSON.stringify(jsonLd)} />