@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.
- package/CHANGELOG.md +309 -34
- package/dist/index.mjs +10 -3
- package/dist/index.mjs.map +1 -1
- package/dist/routes/conversion.d.mts +87 -4
- package/dist/routes/conversion.d.mts.map +1 -1
- package/dist/routes/conversion.mjs +142 -34
- package/dist/routes/conversion.mjs.map +1 -1
- package/package.json +27 -7
- package/src/components/Carousel.astro +13 -2
- package/src/components/Counter.astro +80 -0
- package/src/components/Gallery.astro +218 -0
- package/src/components/Marquee.astro +63 -0
- package/src/components/ResponsiveImage.astro +52 -0
- package/src/components/ReviewCard.astro +60 -0
- package/src/components/ReviewCardBody.astro +67 -0
- package/src/components/Tree.astro +11 -6
- package/src/components/TreeNode.astro +13 -3
- package/src/schemas/Article.astro +2 -2
- package/src/schemas/Breadcrumb.astro +2 -2
- package/src/schemas/Carousel.astro +6 -27
- package/src/schemas/FAQ.astro +2 -2
- package/src/schemas/LocalBusiness.astro +4 -148
- package/src/schemas/Organization.astro +2 -2
- package/src/scripts/tracking.ts +4 -2
- package/virtual.d.ts +0 -11
|
@@ -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">×</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
|
|
14
|
-
* `aria-expanded` bookkeeping —
|
|
15
|
-
*
|
|
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
|
|
26
|
-
*
|
|
27
|
-
*
|
|
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
|
|
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
|
-
|
|
3
|
-
|
|
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
|
|
11
|
-
|
|
12
|
-
|
|
5
|
+
export type { ItemListEntry };
|
|
6
|
+
export type CarouselItem = ItemListEntry;
|
|
7
|
+
export type Props = ItemListGraphOptions;
|
|
13
8
|
|
|
14
|
-
const
|
|
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)} />
|
package/src/schemas/FAQ.astro
CHANGED
|
@@ -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;
|