@getvitops/astro 6.0.0 → 8.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.
@@ -0,0 +1,218 @@
1
+ ---
2
+ /**
3
+ * A `.gallery` thumbnail grid whose triggers open a single `.lightbox-dialog`
4
+ * wrapping a `<Carousel />` of every image — a fully working modal lightbox with
5
+ * zero JavaScript.
6
+ *
7
+ * Tier-3 in the framework's terms — an authoring convenience that emits the right
8
+ * `.gallery` / `.lightbox-dialog` markup and needs no runtime JS to be usable:
9
+ *
10
+ * - each thumbnail is a real `<a href="#slide-id">` into the carousel's own
11
+ * `<li id="slide-id">`, so following it is ordinary fragment navigation —
12
+ * no Invoker Commands, no polyfill, in every engine;
13
+ * - `:has(:target)` in `patterns/lightbox.css` is what reveals the dialog once
14
+ * that navigation lands, and the browser's own fragment scroll puts the
15
+ * carousel on the right slide;
16
+ * - prev/next, dots, arrow keys and swipe are the carousel's — reused, not
17
+ * re-implemented, and already dual-path (native `::scroll-button()` /
18
+ * `::scroll-marker` where the engine has them, real fallback buttons where
19
+ * it doesn't).
20
+ *
21
+ * **This component emits its own `<wc-gallery>`, wrapping its own `<Carousel />`
22
+ * (which emits its own `<wc-carousel>`)** — do NOT wrap either in one yourself.
23
+ * That is the tier-3 rule ("the wrapper emits its `<wc-*>` tag with the
24
+ * accessible fallback inside"). `<wc-gallery>` only intercepts a trigger click to
25
+ * `showModal()` the dialog and run the thumbnail → full-image view-transition
26
+ * morph; it does not step between slides — the nested `<wc-carousel>` does that,
27
+ * and if it never loads, the dialog is still a plain scroll-snap strip.
28
+ *
29
+ * `enhance={false}` on `<Gallery />` still emits a real, working carousel inside
30
+ * the dialog (`enhance` is passed through) — the fallback here is not a stripped
31
+ * lightbox, it is a genuine one, because a scroll-snap carousel needs no
32
+ * `<wc-gallery>` at all to already have prev/next.
33
+ *
34
+ * Images take `string | ImageMetadata` via `<ResponsiveImage />`: a plain string
35
+ * renders a raw `<img>` (unchanged from before `ImageMetadata` support existed);
36
+ * an `astro:assets` import renders through `<Picture />` for AVIF/WebP
37
+ * derivatives. `formats` is passed straight through, including a value
38
+ * astro:assets doesn't recognise yet — see `ResponsiveImage.astro`'s docblock for
39
+ * why (no hard-coded closed format union).
40
+ */
41
+ import type { HTMLAttributes } from 'astro/types';
42
+ import Carousel from './Carousel.astro';
43
+ import ResponsiveImage from './ResponsiveImage.astro';
44
+ import type { ImageFormat } from './ResponsiveImage.astro';
45
+
46
+ export interface GalleryImage {
47
+ /** Full-size image, shown in the dialog. */
48
+ src: string | ImageMetadata;
49
+ /** Thumbnail. Defaults to `src` — for an `ImageMetadata` source, rendered at `thumbWidth`. */
50
+ thumb?: string | ImageMetadata;
51
+ /** Pixel width to request for the thumbnail derivative when `src`/`thumb` is `ImageMetadata`. */
52
+ thumbWidth?: number;
53
+ alt: string;
54
+ caption?: string;
55
+ width?: number;
56
+ height?: number;
57
+ /** Only meaningful when `src` is a plain string — see `ResponsiveImage.astro`. */
58
+ srcset?: string;
59
+ sizes?: string;
60
+ }
61
+
62
+ export interface Props extends Omit<HTMLAttributes<'div'>, 'slot'> {
63
+ /** Accessible name for the grid, and for the dialog it opens. Required. */
64
+ label: string;
65
+ images?: GalleryImage[];
66
+ /** Passed to every `<Picture formats>`. Default `['avif', 'webp']`. */
67
+ formats?: ImageFormat[];
68
+ /** `--gallery-thumb-size` — minimum thumbnail track width. Default `150px`. */
69
+ thumbSize?: string;
70
+ /** `--gallery-gap`. Default `0.5rem`. */
71
+ gap?: string;
72
+ /** `--gallery-aspect` — every thumbnail's box ratio. Default `1`. */
73
+ aspect?: string;
74
+ /** Wrap prev/next past the ends instead of stopping there. Passed to `<Carousel loop>`. */
75
+ loop?: boolean;
76
+ /**
77
+ * Emit the `<wc-gallery>` wrapper (default `true`). Also controls `<Carousel
78
+ * enhance>` — set false where no runtime JS will ever load at all (an email, a
79
+ * PDF); the dialog still opens via `:target` and the carousel is still a real,
80
+ * scroll-snapping strip, just without the morph, autoplay-adjacent polish, or
81
+ * (outside Chromium today) prev/next buttons.
82
+ */
83
+ enhance?: boolean;
84
+ /**
85
+ * Id prefix for the dialog and slide ids. Defaults to a slug of `label`. Astro
86
+ * has no `useId`, and a random one would change on every build — so this is
87
+ * derived, deterministic and overridable when two galleries on a page share a
88
+ * label.
89
+ */
90
+ id?: string;
91
+ }
92
+
93
+ const {
94
+ label,
95
+ images = [],
96
+ formats = ['avif', 'webp'],
97
+ thumbSize,
98
+ gap,
99
+ aspect,
100
+ loop = false,
101
+ enhance = true,
102
+ id,
103
+ class: className,
104
+ role,
105
+ style,
106
+ ...rest
107
+ } = Astro.props;
108
+
109
+ const uid =
110
+ id ??
111
+ `gallery-${
112
+ label
113
+ .toLowerCase()
114
+ .replace(/[^a-z0-9]+/g, '-')
115
+ .replace(/^-|-$/g, '') || 'x'
116
+ }`;
117
+
118
+ const vars = [
119
+ thumbSize ? `--gallery-thumb-size:${thumbSize}` : '',
120
+ gap ? `--gallery-gap:${gap}` : '',
121
+ aspect ? `--gallery-aspect:${aspect}` : '',
122
+ style ?? '',
123
+ ]
124
+ .filter(Boolean)
125
+ .join(';');
126
+
127
+ const Shell = enhance ? 'wc-gallery' : 'div';
128
+ ---
129
+
130
+ <Shell
131
+ class:list={['gallery-shell', className]}
132
+ role={role ?? 'group'}
133
+ aria-label={label}
134
+ style={vars}
135
+ {...rest}
136
+ >
137
+ <ul class="gallery" role="list">
138
+ {
139
+ images.map((img, i) => {
140
+ const thumb = img.thumb ?? img.src;
141
+ return (
142
+ <li class="gallery__item">
143
+ <a class="gallery__trigger" href={`#${uid}-img-${i}`}>
144
+ <ResponsiveImage
145
+ src={thumb}
146
+ alt={img.alt}
147
+ formats={formats}
148
+ class="gallery__thumb"
149
+ width={typeof thumb === 'string' ? undefined : (img.thumbWidth ?? 400)}
150
+ loading={i === 0 ? 'eager' : 'lazy'}
151
+ fetchpriority={i === 0 ? 'high' : 'low'}
152
+ decoding={i === 0 ? undefined : 'async'}
153
+ />
154
+ <span class="visually-hidden">View full size</span>
155
+ </a>
156
+ </li>
157
+ );
158
+ })
159
+ }
160
+ <slot />
161
+ </ul>
162
+ <dialog id={uid} class="lightbox-dialog" closedby="any" aria-label={label}>
163
+ <Carousel
164
+ id={`${uid}-track`}
165
+ label={label}
166
+ class="lightbox-dialog__track"
167
+ enhance={enhance}
168
+ loop={loop}
169
+ hint={false}
170
+ >
171
+ {
172
+ images.map((img, i) => {
173
+ const thumb = img.thumb ?? img.src;
174
+ // A per-slide placeholder: whatever's already on screen (the
175
+ // thumbnail) stays painted in `.carousel__media`'s background until
176
+ // the full image finishes loading, so the view-transition morph
177
+ // never has an empty box as its target — see lightbox.css.
178
+ const placeholder = typeof thumb === 'string' ? `--lightbox-placeholder:url(${JSON.stringify(thumb)})` : '';
179
+ return (
180
+ <li class="carousel__slide" id={`${uid}-img-${i}`}>
181
+ <figure class="carousel__media" style={placeholder}>
182
+ <ResponsiveImage
183
+ src={img.src}
184
+ alt={img.alt}
185
+ formats={formats}
186
+ width={img.width}
187
+ height={img.height}
188
+ srcset={img.srcset}
189
+ sizes={img.sizes}
190
+ loading="lazy"
191
+ />
192
+ {img.caption && <figcaption class="carousel__caption font-caption">{img.caption}</figcaption>}
193
+ </figure>
194
+ </li>
195
+ );
196
+ })
197
+ }
198
+ </Carousel>
199
+ <a class="lightbox-dialog__close" href="#" aria-label="Close">&times;</a>
200
+ </dialog>
201
+ </Shell>
202
+
203
+ <style>
204
+ /* Icon-only trigger keeps its label for screen readers. Component-scoped
205
+ rather than the framework's `sr-only`: that one is in the generator's
206
+ TW_CLASH list, so the tailwind format ships no rule for it. */
207
+ .visually-hidden {
208
+ position: absolute;
209
+ inline-size: 1px;
210
+ block-size: 1px;
211
+ padding: 0;
212
+ margin: -1px;
213
+ overflow: hidden;
214
+ clip: rect(0, 0, 0, 0);
215
+ white-space: nowrap;
216
+ border: 0;
217
+ }
218
+ </style>
@@ -0,0 +1,63 @@
1
+ ---
2
+ /**
3
+ * A `.marquee` — content scrolling seamlessly along a track (a strip of
4
+ * testimonials, logos, a ticker).
5
+ *
6
+ * Tier-3 like `Counter.astro`: the pattern is pure CSS
7
+ * (`@getvitops/core/css/patterns/marquee.css`'s `@keyframes marquee-scroll`), and
8
+ * `<wc-marquee>` only fixes the seam gap by cloning `.marquee__content` enough
9
+ * times to cover the track — "how many copies cover this container" is a
10
+ * measurement CSS cannot make. With no JS (or `enhance={false}`), this still
11
+ * scrolls exactly as authored; it just isn't seamless with a short content run.
12
+ *
13
+ * **This component emits its own `<wc-marquee>`** — do NOT wrap it in one, same
14
+ * rule as `Counter.astro`/`Carousel.astro`.
15
+ *
16
+ * Put the items in the default slot — a list of `<ReviewCard>`s, logos,
17
+ * whatever scrolls. They become the one `.marquee__content` copy the element
18
+ * clones from.
19
+ */
20
+ import type { HTMLAttributes } from 'astro/types';
21
+
22
+ export interface Props extends Omit<HTMLAttributes<'div'>, 'slot'> {
23
+ /** Pause the scroll on hover (default false). */
24
+ pauseOnHover?: boolean;
25
+ /** Scroll in the opposite direction (default false). */
26
+ reverse?: boolean;
27
+ /** Scroll along the block axis instead of inline (default false). */
28
+ vertical?: boolean;
29
+ /**
30
+ * Emit the `<wc-marquee>` wrapper (default `true`).
31
+ *
32
+ * Set false where the element cannot run. The scroll animation still works —
33
+ * only the seamless-loop fixup goes.
34
+ */
35
+ enhance?: boolean;
36
+ }
37
+
38
+ const {
39
+ pauseOnHover = false,
40
+ reverse = false,
41
+ vertical = false,
42
+ enhance = true,
43
+ class: className,
44
+ ...rest
45
+ } = Astro.props;
46
+
47
+ const Shell = enhance ? 'wc-marquee' : 'div';
48
+ ---
49
+
50
+ <Shell
51
+ class:list={[
52
+ 'marquee',
53
+ pauseOnHover && 'marquee--pause-hover',
54
+ reverse && 'marquee--reverse',
55
+ vertical && 'marquee--vertical',
56
+ className,
57
+ ]}
58
+ {...rest}
59
+ >
60
+ <div class="marquee__content">
61
+ <slot />
62
+ </div>
63
+ </Shell>
@@ -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
+ }
@@ -0,0 +1,60 @@
1
+ ---
2
+ /**
3
+ * One `.review` card, from a `vitops reviews` cache entry
4
+ * (`@getvitops/utils/reviews`'s `Review`).
5
+ *
6
+ * Pure tier-3: `.review` is a config-generated pattern
7
+ * (`patterns.items.review`), so this needs no runtime JS, no `<wc-*>` tag, and
8
+ * therefore no `enhance` prop. Put several inside `<Carousel>` slides or
9
+ * `<Marquee>` items — there is no group component.
10
+ *
11
+ * **Named `ReviewCard`, not `Review`.** `../schemas/Review.astro` already owns
12
+ * that name for the JSON-LD builder this component composes when `schema` is
13
+ * true; two same-named components differing only by directory is a needless
14
+ * import trap.
15
+ *
16
+ * **`schema` defaults to false.** Google's structured-data policy treats a
17
+ * business marking up reviews of itself as ineligible for review rich results,
18
+ * and risks a manual action — see AGENTS.md's Reviews section. Pass
19
+ * `itemReviewed` (the business the review is of) only when you have deliberately
20
+ * decided to emit it anyway; without it, `schema` is a no-op.
21
+ *
22
+ * **`avatar` defaults to false.** A reviewer's photo is a Google-hosted URL —
23
+ * rendering it by default would put a third-party request on every page and
24
+ * depend on a link that isn't guaranteed to stay live. With it off,
25
+ * `.review__initials` carries the author's initials instead.
26
+ */
27
+ import type { ComponentProps, HTMLAttributes } from 'astro/types';
28
+ import type { Review } from '@getvitops/utils/reviews';
29
+ import ReviewCardBody from './ReviewCardBody.astro';
30
+ import SchemaReview from '../schemas/Review.astro';
31
+
32
+ export interface Props extends Omit<HTMLAttributes<'article'>, 'slot'> {
33
+ review: Review;
34
+ /** Emit `Review` JSON-LD via `../schemas/Review.astro` (default false). */
35
+ schema?: boolean;
36
+ /** Required for `schema` to take effect — the business this review is of. */
37
+ itemReviewed?: ComponentProps<typeof SchemaReview>['itemReviewed'];
38
+ /** Render the reviewer's Google-hosted avatar photo (default false). */
39
+ avatar?: boolean;
40
+ }
41
+
42
+ const { review, schema = false, itemReviewed, avatar = false, ...rest } = Astro.props;
43
+ ---
44
+
45
+ {
46
+ schema && itemReviewed ? (
47
+ <SchemaReview
48
+ itemReviewed={itemReviewed}
49
+ reviewRating={{ ratingValue: review.rating }}
50
+ author={{ name: review.author }}
51
+ datePublished={review.publishedAt}
52
+ reviewBody={review.text}
53
+ publisher={{ name: 'Google', type: 'Organization' }}
54
+ >
55
+ <ReviewCardBody review={review} avatar={avatar} {...rest} />
56
+ </SchemaReview>
57
+ ) : (
58
+ <ReviewCardBody review={review} avatar={avatar} {...rest} />
59
+ )
60
+ }
@@ -0,0 +1,67 @@
1
+ ---
2
+ /**
3
+ * The `.review` markup itself — split out of `ReviewCard.astro` purely so it can
4
+ * be rendered twice (once bare, once wrapped in `../schemas/Review.astro`'s
5
+ * JSON-LD `<script>`) without duplicating the template. Not a public export;
6
+ * `ReviewCard.astro` is the tier-3 entry point.
7
+ */
8
+ import type { HTMLAttributes } from 'astro/types';
9
+ import type { Review } from '@getvitops/utils/reviews';
10
+ import Icon from './Icon.astro';
11
+
12
+ export interface Props extends Omit<HTMLAttributes<'article'>, 'slot'> {
13
+ review: Review;
14
+ avatar: boolean;
15
+ }
16
+
17
+ const { review, avatar, class: className, ...rest } = Astro.props;
18
+
19
+ const initials = review.author
20
+ .split(/\s+/)
21
+ .filter(Boolean)
22
+ .slice(0, 2)
23
+ .map((w) => w[0]?.toUpperCase())
24
+ .join('');
25
+
26
+ const full = Math.floor(review.rating);
27
+ const hasHalf = review.rating - full >= 0.5;
28
+ ---
29
+
30
+ <article class:list={['review', className]} {...rest}>
31
+ <header class="review__header">
32
+ {
33
+ avatar && review.authorPhoto ? (
34
+ <img class="review__avatar" src={review.authorPhoto} alt="" loading="lazy" />
35
+ ) : (
36
+ <span class="review__initials" aria-hidden="true">{initials}</span>
37
+ )
38
+ }
39
+ <div class="review__identity">
40
+ <cite class="review__author">{review.author}</cite>
41
+ <time class="review__date" datetime={review.publishedAt}>
42
+ {new Date(review.publishedAt).toLocaleDateString()}
43
+ </time>
44
+ </div>
45
+ <span class="review__stars" role="img" aria-label={`${review.rating} out of 5 stars`}>
46
+ {Array.from({ length: 5 }, (_, i) => {
47
+ const half = i === full && hasHalf;
48
+ const empty = i >= full && !half;
49
+ return (
50
+ <Icon
51
+ name={half ? 'star-half' : 'star'}
52
+ class:list={['review__star', empty && 'review__star--empty']}
53
+ />
54
+ );
55
+ })}
56
+ </span>
57
+ </header>
58
+ {review.text && <blockquote class="review__body">{review.text}</blockquote>}
59
+ {
60
+ review.reply && (
61
+ <footer class="review__reply">
62
+ <p class="review__reply-author">Response from the owner</p>
63
+ <p>{review.reply.text}</p>
64
+ </footer>
65
+ )
66
+ }
67
+ </article>
@@ -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,6 +1,6 @@
1
1
  ---
2
- import { articleGraph } from '@getvitops/utils';
3
- import type { ArticleGraphOptions } from '@getvitops/utils';
2
+ import { articleGraph } from '@getvitops/utils/schema';
3
+ import type { ArticleGraphOptions } from '@getvitops/utils/schema';
4
4
 
5
5
  export type Props = ArticleGraphOptions;
6
6
 
@@ -1,6 +1,6 @@
1
1
  ---
2
- import { breadcrumbGraph } from '@getvitops/utils';
3
- import type { BreadcrumbGraphOptions, BreadcrumbItem } from '@getvitops/utils';
2
+ import { breadcrumbGraph } from '@getvitops/utils/schema';
3
+ import type { BreadcrumbGraphOptions, BreadcrumbItem } from '@getvitops/utils/schema';
4
4
 
5
5
  export type { BreadcrumbItem };
6
6
  export type Props = BreadcrumbGraphOptions;
@@ -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/schema';
3
+ import type { ItemListEntry, ItemListGraphOptions } from '@getvitops/utils/schema';
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)} />
@@ -5,8 +5,8 @@
5
5
  * NOTE: Google recommends FAQ markup only for authoritative government
6
6
  * and health sites. For other sites, consider using QAPage schema instead.
7
7
  */
8
- import { faqGraph } from '@getvitops/utils';
9
- import type { FAQGraphOptions, FAQItem } from '@getvitops/utils';
8
+ import { faqGraph } from '@getvitops/utils/schema';
9
+ import type { FAQGraphOptions, FAQItem } from '@getvitops/utils/schema';
10
10
 
11
11
  export type { FAQItem };
12
12
  export type Props = FAQGraphOptions;