@getvitops/astro 7.0.0 → 8.0.1

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.
@@ -1,32 +1,35 @@
1
1
  ---
2
2
  /**
3
- * A `.gallery` thumbnail grid whose triggers open per-image `.lightbox-dialog`s —
4
- * a fully working modal lightbox with zero JavaScript.
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.
5
6
  *
6
7
  * Tier-3 in the framework's terms — an authoring convenience that emits the right
7
8
  * `.gallery` / `.lightbox-dialog` markup and needs no runtime JS to be usable:
8
9
  *
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.
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).
19
20
  *
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.
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.
25
28
  *
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`).
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.
30
33
  *
31
34
  * Images take `string | ImageMetadata` via `<ResponsiveImage />`: a plain string
32
35
  * renders a raw `<img>` (unchanged from before `ImageMetadata` support existed);
@@ -36,6 +39,7 @@
36
39
  * why (no hard-coded closed format union).
37
40
  */
38
41
  import type { HTMLAttributes } from 'astro/types';
42
+ import Carousel from './Carousel.astro';
39
43
  import ResponsiveImage from './ResponsiveImage.astro';
40
44
  import type { ImageFormat } from './ResponsiveImage.astro';
41
45
 
@@ -56,7 +60,7 @@ export interface GalleryImage {
56
60
  }
57
61
 
58
62
  export interface Props extends Omit<HTMLAttributes<'div'>, 'slot'> {
59
- /** Accessible name for the grid. Required — an unnamed group is unfindable. */
63
+ /** Accessible name for the grid, and for the dialog it opens. Required. */
60
64
  label: string;
61
65
  images?: GalleryImage[];
62
66
  /** Passed to every `<Picture formats>`. Default `['avif', 'webp']`. */
@@ -67,21 +71,21 @@ export interface Props extends Omit<HTMLAttributes<'div'>, 'slot'> {
67
71
  gap?: string;
68
72
  /** `--gallery-aspect` — every thumbnail's box ratio. Default `1`. */
69
73
  aspect?: string;
70
- /** Wrap prev/next past the ends instead of stopping there. */
74
+ /** Wrap prev/next past the ends instead of stopping there. Passed to `<Carousel loop>`. */
71
75
  loop?: boolean;
72
76
  /**
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.
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.
79
82
  */
80
83
  enhance?: boolean;
81
84
  /**
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
+ * 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.
85
89
  */
86
90
  id?: string;
87
91
  }
@@ -127,7 +131,6 @@ const Shell = enhance ? 'wc-gallery' : 'div';
127
131
  class:list={['gallery-shell', className]}
128
132
  role={role ?? 'group'}
129
133
  aria-label={label}
130
- loop={enhance && loop ? '' : undefined}
131
134
  style={vars}
132
135
  {...rest}
133
136
  >
@@ -137,12 +140,7 @@ const Shell = enhance ? 'wc-gallery' : 'div';
137
140
  const thumb = img.thumb ?? img.src;
138
141
  return (
139
142
  <li class="gallery__item">
140
- <button
141
- class="gallery__trigger"
142
- type="button"
143
- command="show-modal"
144
- commandfor={`${uid}-img-${i}`}
145
- >
143
+ <a class="gallery__trigger" href={`#${uid}-img-${i}`}>
146
144
  <ResponsiveImage
147
145
  src={thumb}
148
146
  alt={img.alt}
@@ -154,42 +152,52 @@ const Shell = enhance ? 'wc-gallery' : 'div';
154
152
  decoding={i === 0 ? undefined : 'async'}
155
153
  />
156
154
  <span class="visually-hidden">View full size</span>
157
- </button>
155
+ </a>
158
156
  </li>
159
157
  );
160
158
  })
161
159
  }
162
160
  <slot />
163
161
  </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
- }
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>
193
201
  </Shell>
194
202
 
195
203
  <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,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>
@@ -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,6 +1,6 @@
1
1
  ---
2
- import { itemListGraph } from '@getvitops/utils';
3
- import type { ItemListEntry, ItemListGraphOptions } from '@getvitops/utils';
2
+ import { itemListGraph } from '@getvitops/utils/schema';
3
+ import type { ItemListEntry, ItemListGraphOptions } from '@getvitops/utils/schema';
4
4
 
5
5
  export type { ItemListEntry };
6
6
  export type CarouselItem = ItemListEntry;
@@ -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;
@@ -1,6 +1,6 @@
1
1
  ---
2
- import { localBusinessGraph } from '@getvitops/utils';
3
- import type { LocalBusinessGraphOptions } from '@getvitops/utils';
2
+ import { localBusinessGraph } from '@getvitops/utils/schema';
3
+ import type { LocalBusinessGraphOptions } from '@getvitops/utils/schema';
4
4
 
5
5
  export type Props = LocalBusinessGraphOptions;
6
6
 
@@ -1,6 +1,6 @@
1
1
  ---
2
- import { organizationGraph } from '@getvitops/utils';
3
- import type { OrganizationGraphOptions } from '@getvitops/utils';
2
+ import { organizationGraph } from '@getvitops/utils/schema';
3
+ import type { OrganizationGraphOptions } from '@getvitops/utils/schema';
4
4
 
5
5
  export type Props = OrganizationGraphOptions;
6
6
 
@@ -1,5 +1,8 @@
1
1
  // Ad conversion tracking: capture click IDs from the landing URL, track tel:
2
- // clicks. Form enhancement is in form-enhance.ts.
2
+ // clicks. Form submission itself goes through `createConversionRoute()`
3
+ // (`@getvitops/astro/routes`), which handles both the no-JS `<form
4
+ // method="post">` path and a fetch-based one — there is no separate
5
+ // form-enhancement script here.
3
6
  //
4
7
  // Runs early and ungated on purpose. Reading the query string is not storage and
5
8
  // needs nobody's permission; *keeping* it does. So the capture is synchronous —
@@ -100,7 +103,6 @@ function persist(): void {
100
103
  ts: existing?.['ts'] ?? Date.now(),
101
104
  };
102
105
 
103
- // Remove undefined values before serializing
104
106
  const clean = Object.fromEntries(Object.entries(merged).filter(([, v]) => v !== undefined));
105
107
 
106
108
  const expires = new Date(Date.now() + COOKIE_DAYS * 864e5).toUTCString();
package/virtual.d.ts CHANGED
@@ -1,14 +1,3 @@
1
- /**
2
- * Astro generates the real types for `astro:env/*` into a consumer's
3
- * `.astro/` on `astro sync`, which never runs for this package — so the
4
- * import typechecks nowhere but a consumer project. Declared here at the
5
- * shape `plan-features.ts` actually uses; the runtime value comes from
6
- * Astro's env schema, and this file cannot import it.
7
- */
8
- declare module 'astro:env/server' {
9
- export const SITE_PLAN: string | undefined;
10
- }
11
-
12
1
  /** Types for the virtual module the getvitops() integration provides to <Head />. */
13
2
  declare module 'virtual:getvitops/head' {
14
3
  interface FaviconLink {