@contentrain/astro-kit 0.46.2 → 0.47.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/catalog.json CHANGED
@@ -105,6 +105,7 @@
105
105
  "KitImage.astro",
106
106
  "Lines.astro",
107
107
  "Section.astro",
108
+ "card-image.ts",
108
109
  "image-widths.ts",
109
110
  "instance-id.ts",
110
111
  "measured.ts",
@@ -790,6 +791,7 @@
790
791
  "shared": [
791
792
  "KitImage.astro",
792
793
  "Section.astro",
794
+ "card-image.ts",
793
795
  "image-widths.ts",
794
796
  "image.ts",
795
797
  "instance-id.ts",
@@ -2401,6 +2403,16 @@
2401
2403
  "type": "string",
2402
2404
  "description": "Poster action for a map or widget (ui-strings embed.show)."
2403
2405
  },
2406
+ "schema": {
2407
+ "type": "boolean",
2408
+ "default": true,
2409
+ "content": false,
2410
+ "description": "A VideoObject (JSON-LD) for a video with a title: name, caption as description, page and embed url, the local poster or the YouTube still as thumbnail (a URL in the data, not a request). On by default; a video with no title, a map or a player prints none. false: none."
2411
+ },
2412
+ "uploaded": {
2413
+ "type": "string",
2414
+ "description": "The video's upload date (ISO 8601), printed as uploadDate only when the source states it."
2415
+ },
2404
2416
  "id": {
2405
2417
  "type": "string",
2406
2418
  "description": "Element id."
@@ -2443,6 +2455,7 @@
2443
2455
  "instance-id.ts",
2444
2456
  "measured.ts",
2445
2457
  "muted.ts",
2458
+ "once-per-page.ts",
2446
2459
  "safe-url.ts",
2447
2460
  "types.ts"
2448
2461
  ],
@@ -2547,9 +2560,9 @@
2547
2560
  },
2548
2561
  "schema": {
2549
2562
  "type": "boolean",
2550
- "default": false,
2563
+ "default": true,
2551
2564
  "content": false,
2552
- "description": "Emit FAQPage JSON-LD built from these items. Use on at most one FAQ per page, and only when the page is really a FAQ."
2565
+ "description": "FAQPage JSON-LD built from the answered items. On by default; only the first FAQ on a page that has an answered question prints it. false: none (questions that are not the page's own FAQ)."
2553
2566
  },
2554
2567
  "measured": {
2555
2568
  "type": "object",
@@ -5015,6 +5028,7 @@
5015
5028
  "uses": [],
5016
5029
  "shared": [
5017
5030
  "KitImage.astro",
5031
+ "card-image.ts",
5018
5032
  "image-widths.ts",
5019
5033
  "safe-url.ts",
5020
5034
  "types.ts"
@@ -7824,6 +7838,7 @@
7824
7838
  "shared": [
7825
7839
  "KitImage.astro",
7826
7840
  "Section.astro",
7841
+ "card-image.ts",
7827
7842
  "image-widths.ts",
7828
7843
  "image.ts",
7829
7844
  "instance-id.ts",
@@ -0,0 +1,13 @@
1
+ import type { ImageInput } from './types'
2
+
3
+ const spoken = (text: string): string => text.replace(/\s+/g, ' ').trim().replace(/[.:;!]+$/, '').toLowerCase()
4
+
5
+ /**
6
+ * A card's image beside its title link: when the title's link covers the card (a stretched link) and the image's text
7
+ * only repeats the title, the image is drawn with an empty text, so a screen reader says the title once. The content
8
+ * keeps its text (Studio, image search); an image outside such a card, or with words of its own, is drawn as written.
9
+ */
10
+ export function cardImage(image: ImageInput | undefined, title: string | undefined, linked: boolean): ImageInput | undefined {
11
+ if (!image || !linked || !image.alt || !title || spoken(image.alt) !== spoken(title)) return image
12
+ return { ...image, alt: '' }
13
+ }
@@ -0,0 +1,17 @@
1
+ // Something a page states once, whichever component prints it: the first FAQ on a page carries the page's FAQPage, a
2
+ // second one would state a second FAQPage for the same page. Taken in the order the page renders its components, the
3
+ // same on every build (`page` = `Astro.locals`, as in instance-id.ts).
4
+
5
+ const claimed = new WeakMap<object, Set<string>>()
6
+
7
+ /** True for the first caller on this page that claims `what`, false for every later one. */
8
+ export function firstOnPage(page: object, what: string): boolean {
9
+ let taken = claimed.get(page)
10
+ if (!taken) {
11
+ taken = new Set()
12
+ claimed.set(page, taken)
13
+ }
14
+ if (taken.has(what)) return false
15
+ taken.add(what)
16
+ return true
17
+ }
@@ -12,6 +12,7 @@ import type { ActionInput, ImageInput, MeasuredStyle } from '../_shared/types'
12
12
  import { safeHref } from '../_shared/safe-url'
13
13
  import { instanceId } from '../_shared/instance-id'
14
14
  import { muted } from '../_shared/muted'
15
+ import { cardImage } from '../_shared/card-image'
15
16
 
16
17
  const band = tv({
17
18
  slots: {
@@ -134,7 +135,7 @@ const hasCopy = Boolean(heading || eyebrow || lead || actions.length)
134
135
  <ul class={s.list({ class: measuredGrid(measured) })}>
135
136
  {items.map(item => (
136
137
  <li class={s.item()}>
137
- {item.media && <div class={s.itemMedia()}><KitImage image={{ src: item.media, alt: item.mediaAlt ?? '' }} sizes="(min-width: 1024px) 33vw, (min-width: 640px) 50vw, 100vw" class="aspect-[3/2] w-full object-cover" /></div>}
138
+ {item.media && <div class={s.itemMedia()}><KitImage image={cardImage({ src: item.media, alt: item.mediaAlt ?? '' }, item.title, !!item.href && !item.linkLabel)!} sizes="(min-width: 1024px) 33vw, (min-width: 640px) 50vw, 100vw" class="aspect-[3/2] w-full object-cover" /></div>}
138
139
  {item.icon && <KitImage image={{ src: item.icon, alt: '' }} class="h-10 w-10 object-contain" />}
139
140
  {item.value && <p class={s.itemValue()}>{item.value}</p>}
140
141
  {item.title && (
@@ -9,6 +9,7 @@
9
9
  "KitImage.astro",
10
10
  "Lines.astro",
11
11
  "Section.astro",
12
+ "card-image.ts",
12
13
  "image-widths.ts",
13
14
  "instance-id.ts",
14
15
  "measured.ts",
@@ -7,6 +7,7 @@ import Section, { type Tone } from '../_shared/Section.astro'
7
7
  import { instanceId } from '../_shared/instance-id'
8
8
  import { measuredGrid } from '../_shared/measured'
9
9
  import type { MeasuredStyle } from '../_shared/types'
10
+ import { cardImage } from '../_shared/card-image'
10
11
  import { imageOf, type ImageSource } from '../_shared/image'
11
12
  import { safeHref } from '../_shared/safe-url'
12
13
  import { muted, mutedReset } from '../_shared/muted'
@@ -130,7 +131,7 @@ const number = (i: number) => String(i + 1).padStart(2, '0')
130
131
  {item.text ? <p class={r.text()} data-kit-text>{item.text}</p> : <span aria-hidden="true" />}
131
132
  {(imageOf(item.image, item.imageAlt) || (item.href && item.linkLabel)) && (
132
133
  <div class={r.end({ class: imageOf(item.image, item.imageAlt) ? 'md:self-start' : undefined })}>
133
- {imageOf(item.image, item.imageAlt) && <div class={r.media()}><KitImage image={imageOf(item.image, item.imageAlt)!} sizes="(min-width: 768px) 12rem, 100vw" class="aspect-[3/2] w-full object-cover" /></div>}
134
+ {imageOf(item.image, item.imageAlt) && <div class={r.media()}><KitImage image={cardImage(imageOf(item.image, item.imageAlt), item.title, !!item.href && !item.linkLabel)!} sizes="(min-width: 768px) 12rem, 100vw" class="aspect-[3/2] w-full object-cover" /></div>}
134
135
  {item.href && item.linkLabel && <a href={safeHref(item.href)} class={r.link()}>{item.linkLabel}</a>}
135
136
  </div>
136
137
  )}
@@ -141,7 +142,7 @@ const number = (i: number) => String(i + 1).padStart(2, '0')
141
142
  <ul class={list({ class: measuredGrid(measured) })}>
142
143
  {items.map(item => (
143
144
  <li class={card()}>
144
- {imageOf(item.image, item.imageAlt) && <div class={media()}><KitImage image={imageOf(item.image, item.imageAlt)!} sizes="(min-width: 1024px) 33vw, (min-width: 640px) 50vw, 100vw" class="aspect-[3/2] w-full object-cover transition duration-300 group-hover:scale-[1.02]" /></div>}
145
+ {imageOf(item.image, item.imageAlt) && <div class={media()}><KitImage image={cardImage(imageOf(item.image, item.imageAlt), item.title, !!item.href && !item.linkLabel)!} sizes="(min-width: 1024px) 33vw, (min-width: 640px) 50vw, 100vw" class="aspect-[3/2] w-full object-cover transition duration-300 group-hover:scale-[1.02]" /></div>}
145
146
  {item.icon && <KitImage image={{ src: item.icon, alt: '' }} class="h-10 w-10 object-contain" />}
146
147
  {item.eyebrow && <p class="text-sm font-medium text-accent-text">{item.eyebrow}</p>}
147
148
  <CardHeading class={cardTitle()}>
@@ -7,6 +7,7 @@
7
7
  "shared": [
8
8
  "KitImage.astro",
9
9
  "Section.astro",
10
+ "card-image.ts",
10
11
  "image-widths.ts",
11
12
  "image.ts",
12
13
  "instance-id.ts",
@@ -49,10 +49,14 @@ interface Props {
49
49
  showLabel?: string | undefined
50
50
  aspect?: 'auto' | 'video' | 'portrait' | 'square' | 'map' | undefined
51
51
  width?: 'prose' | 'page' | undefined
52
+ /** VideoObject structured data (JSON-LD) for a video with a name (`title` or `caption`); on by default, `false`: none. */
53
+ schema?: boolean | undefined
54
+ /** When the video was published (ISO date), for the VideoObject's uploadDate; left out when unknown. */
55
+ uploaded?: string | undefined
52
56
  id?: string | undefined
53
57
  }
54
58
 
55
- const { url, address, title, caption, poster, playLabel = 'Play', showLabel = 'Show', aspect = 'auto', width, id } = Astro.props
59
+ const { url, address, title, caption, poster, playLabel = 'Play', showLabel = 'Show', aspect = 'auto', width, schema = true, uploaded, id } = Astro.props
56
60
  const target = (url ? embedTarget(url) : null) ?? (address && !url ? mapTarget(address) : null)
57
61
  const shape = aspect !== 'auto' ? aspect : target?.portrait ? 'portrait' : target?.kind === 'map' ? 'map' : 'video'
58
62
  // A player strip (Spotify, SoundCloud) has a fixed height, not a ratio.
@@ -61,6 +65,22 @@ const s = embed({ aspect: strip ? 'auto' : shape, width })
61
65
  const action = target && (target.kind === 'video' || target.kind === 'audio') ? playLabel : showLabel
62
66
  const name = title ?? caption ?? ''
63
67
  const linkUrl = url && /^https?:\/\//i.test(url) ? url : undefined
68
+ // VideoObject states only what the embed knows: its name, its page and player, a poster (the site's own, else the
69
+ // provider's still for a YouTube video: a URL in data, never fetched by the visitor) and an upload date when given.
70
+ const youtubeId = target?.provider === 'youtube' ? /(?:[?&]v=|\/shorts\/)([\w-]{11})/.exec(target.href)?.[1] : undefined
71
+ const thumbnail = poster?.src ? new URL(poster.src, Astro.site ?? Astro.url).href : youtubeId ? `https://i.ytimg.com/vi/${youtubeId}/hqdefault.jpg` : undefined
72
+ const structured = schema && target?.kind === 'video' && name
73
+ ? JSON.stringify({
74
+ '@context': 'https://schema.org',
75
+ '@type': 'VideoObject',
76
+ name,
77
+ ...(caption && caption !== name ? { description: caption } : {}),
78
+ url: target.href,
79
+ embedUrl: target.src.split('?')[0],
80
+ ...(thumbnail ? { thumbnailUrl: thumbnail } : {}),
81
+ ...(uploaded ? { uploadDate: uploaded } : {}),
82
+ }).replace(/</g, '\\u003c')
83
+ : null
64
84
  ---
65
85
  {target
66
86
  ? (
@@ -77,6 +97,7 @@ const linkUrl = url && /^https?:\/\//i.test(url) ? url : undefined
77
97
  </a>
78
98
  </div>
79
99
  {caption && <figcaption class={s.caption()}>{caption}</figcaption>}
100
+ {structured && <script type="application/ld+json" set:html={structured}></script>}
80
101
  </figure>
81
102
  )
82
103
  : linkUrl && (
@@ -17,6 +17,8 @@
17
17
  "poster": { "type": "object", "fields": { "src": { "type": "image", "required": true }, "alt": { "type": "string", "required": true }, "width": { "type": "integer" }, "height": { "type": "integer" } }, "description": "Local poster image (an ImageInput; its alt is not used, the link names the embed). Without one the poster is a plain dark panel. Never a provider thumbnail URL: that would be a third-party request before the click." },
18
18
  "playLabel": { "type": "string", "description": "Poster action for a video or audio player (ui-strings embed.play)." },
19
19
  "showLabel": { "type": "string", "description": "Poster action for a map or widget (ui-strings embed.show)." },
20
+ "schema": { "type": "boolean", "default": true, "content": false, "description": "A VideoObject (JSON-LD) for a video with a title: name, caption as description, page and embed url, the local poster or the YouTube still as thumbnail (a URL in the data, not a request). On by default; a video with no title, a map or a player prints none. false: none." },
21
+ "uploaded": { "type": "string", "description": "The video's upload date (ISO 8601), printed as uploadDate only when the source states it." },
20
22
  "id": { "type": "string", "description": "Element id." }
21
23
  },
22
24
  "slots": {},
@@ -1,13 +1,14 @@
1
1
  ---
2
2
  // Questions and answers as native disclosure widgets (<details>) — no script.
3
- // `exclusive` groups them by name, so opening one closes the others. With
4
- // `schema`, the page also carries FAQPage structured data for search engines
5
- // and AI answers.
3
+ // `exclusive` groups them by name, so opening one closes the others. The first
4
+ // FAQ on a page with an answered question also carries the page's FAQPage
5
+ // structured data for search engines and AI answers (`schema={false}`: none).
6
6
  import { tv } from 'tailwind-variants'
7
7
  import Section from '../_shared/Section.astro'
8
8
  import { measuredColumns } from '../_shared/measured'
9
9
  import type { MeasuredStyle } from '../_shared/types'
10
10
  import { instanceId } from '../_shared/instance-id'
11
+ import { firstOnPage } from '../_shared/once-per-page'
11
12
  import { muted } from '../_shared/muted'
12
13
 
13
14
  export interface FaqItem {
@@ -78,7 +79,7 @@ interface Props {
78
79
  exclusive?: boolean | undefined
79
80
  /** Open the first answer. */
80
81
  openFirst?: boolean | undefined
81
- /** Add FAQPage structured data (JSON-LD). Once per page. */
82
+ /** FAQPage structured data (JSON-LD) from the answered questions; on by default, printed by the first FAQ on the page only. */
82
83
  schema?: boolean | undefined
83
84
  tone?: 'default' | 'muted' | 'inverse' | 'accent' | undefined
84
85
  /** The source section's measured style (`MeasuredStyle`): frame, padding, fill, background image, columns per width, gap. Absent, the site's tokens; present, it wins over tone and columns. */
@@ -88,7 +89,7 @@ interface Props {
88
89
  entry?: string | undefined
89
90
  }
90
91
 
91
- const { items, heading, intro, layout, columns, icon, numbered = false, style, questionStyle, exclusive = false, openFirst = false, schema = false, tone, measured, id: givenId } = Astro.props
92
+ const { items, heading, intro, layout, columns, icon, numbered = false, style, questionStyle, exclusive = false, openFirst = false, schema = true, tone, measured, id: givenId } = Astro.props
92
93
  // Two on one page never share an id: their labels and controls would point into each other.
93
94
  const id = instanceId(Astro.locals, givenId, 'faq')
94
95
  const s = faq({ layout, columns, icon, numbered, style, questionStyle: style === 'plain' ? 'heading' : questionStyle, tone })
@@ -105,9 +106,10 @@ const plain = (html: string) => html.replace(/<[^>]*>/g, ' ').replace(/&(?:nbsp|
105
106
  const MEDIA = /<(?:img|picture|video|audio|iframe|svg|embed|object|canvas)\b/i
106
107
  const answered = items.map(item => plain(item.answer) !== '' || MEDIA.test(item.answer))
107
108
  const firstAnswered = answered.indexOf(true)
108
- // FAQPage needs each question's acceptedAnswer text, so a question without one is left out of it.
109
+ // FAQPage needs each question's acceptedAnswer text, so a question without one is left out of it. One per page: a FAQ
110
+ // with nothing to state does not take the page's turn.
109
111
  const withText = items.filter(item => plain(item.answer) !== '')
110
- const structured = schema && withText.length > 0
112
+ const structured = schema && withText.length > 0 && firstOnPage(Astro.locals, 'faq-page')
111
113
  ? JSON.stringify({
112
114
  '@context': 'https://schema.org',
113
115
  '@type': 'FAQPage',
@@ -4,7 +4,7 @@
4
4
  "description": "Questions and answers as native <details> disclosures (no script), in lines or cards; optionally one open at a time, and optionally with FAQPage structured data for search engines and AI answers.",
5
5
  "category": "section",
6
6
  "uses": [],
7
- "shared": ["Section.astro", "instance-id.ts", "measured.ts", "muted.ts", "safe-url.ts", "types.ts"],
7
+ "shared": ["Section.astro", "instance-id.ts", "measured.ts", "muted.ts", "once-per-page.ts", "safe-url.ts", "types.ts"],
8
8
  "variants": {
9
9
  "layout": { "options": ["single", "split"], "default": "single", "description": "single: the heading above one column. split: the heading and introduction stay in a left column beside the list; stacks on a phone." },
10
10
  "columns": { "options": ["1", "2"], "default": "1", "description": "Columns of questions on wide screens." },
@@ -19,7 +19,7 @@
19
19
  "intro": { "type": "text", "label": "Introduction", "description": "A sentence under the heading." },
20
20
  "exclusive": { "type": "boolean", "default": false, "content": false, "description": "One answer open at a time (Elementor accordion, Divi accordion). Off for toggles." },
21
21
  "openFirst": { "type": "boolean", "default": false, "content": false, "description": "The first answer starts open (Divi open_toggle, Elementor active item 1)." },
22
- "schema": { "type": "boolean", "default": false, "content": false, "description": "Emit FAQPage JSON-LD built from these items. Use on at most one FAQ per page, and only when the page is really a FAQ." },
22
+ "schema": { "type": "boolean", "default": true, "content": false, "description": "FAQPage JSON-LD built from the answered items. On by default; only the first FAQ on a page that has an answered question prints it. false: none (questions that are not the page's own FAQ)." },
23
23
  "measured": { "type": "object", "content": false, "description": "The source section's measured style (MeasuredStyle): frame width, padding, fill, image, columns per width, gap, text tone, radius, minimum height — all in pixels. Absent, the site's tokens; present, it wins over tone and columns. Values that are not a number, a colour or a known keyword are dropped.", "fields": { "containerPx": { "type": "integer", "min": 0, "max": 2560, "description": "The frame's content width." }, "padY": { "type": "integer", "min": 0, "max": 400, "description": "Top and bottom padding." }, "padX": { "type": "integer", "min": 0, "max": 200, "description": "The frame's side padding (gutter)." }, "bg": { "type": "object", "description": "The section's background.", "fields": { "color": { "type": "string", "description": "Fill colour: hex, rgb(a) or hsl(a)." }, "image": { "type": "string", "description": "Background image address (http(s) or root-relative), drawn behind the content." }, "overlay": { "type": "string", "description": "Colour or linear/radial gradient laid over the image or the fill." }, "size": { "type": "string", "description": "cover, contain or auto; anything else draws as cover." }, "position": { "type": "string", "description": "One or two of center/top/bottom/left/right or percentages." } } }, "columns": { "type": "object", "description": "Item columns at 390 (base), 768 (md) and 1280 (lg) px; a missing width follows the narrower one.", "fields": { "390": { "type": "integer", "min": 1, "max": 6, "description": "Phone." }, "768": { "type": "integer", "min": 1, "max": 6, "description": "Tablet." }, "1280": { "type": "integer", "min": 1, "max": 6, "description": "Desktop." } } }, "gap": { "type": "integer", "min": 0, "max": 200, "description": "Gap between items." }, "tone": { "type": "select", "options": ["light", "dark"], "description": "What the text is set for: dark = light text on a dark band. Without it, a fill's own lightness decides." }, "align": { "type": "select", "options": ["start", "center", "end"], "description": "Where the heading and lead sit; read by a component whose own align is not set." }, "radius": { "type": "integer", "min": 0, "max": 200, "description": "Card and item corner radius." }, "minHeight": { "type": "integer", "min": 0, "max": 2000, "description": "The section's minimum height." } } },
24
24
  "entry": { "type": "string", "content": false, "description": "The page entry this section is built from, without its locale (services-0): printed as data-kit-entry, the hook a design layer styles this one section by. Absent, none is printed." },
25
25
  "id": { "type": "string", "content": false, "description": "Section id; also the exclusive group's name." },
@@ -8,6 +8,7 @@ import { tv } from 'tailwind-variants'
8
8
  import KitImage from '../_shared/KitImage.astro'
9
9
  import type { ImageInput, LinkInput } from '../_shared/types'
10
10
  import { safeHref } from '../_shared/safe-url'
11
+ import { cardImage } from '../_shared/card-image'
11
12
 
12
13
  const card = tv({
13
14
  slots: {
@@ -82,7 +83,7 @@ const Heading = `h${headingLevel}` as 'h2' | 'h3'
82
83
  <article class={slots.root()}>
83
84
  {image && layout !== 'minimal' && (
84
85
  <div class={slots.media()}>
85
- <KitImage image={image} sizes={sizes ?? (layout === 'horizontal' ? '256px' : '(min-width: 1024px) 360px, 100vw')} class:list={['w-full object-cover transition duration-300 group-hover:scale-[1.02]', !imageStyle && 'aspect-[16/9]']} style={imageStyle} />
86
+ <KitImage image={cardImage(image, title, !!href)!} sizes={sizes ?? (layout === 'horizontal' ? '256px' : '(min-width: 1024px) 360px, 100vw')} class:list={['w-full object-cover transition duration-300 group-hover:scale-[1.02]', !imageStyle && 'aspect-[16/9]']} style={imageStyle} />
86
87
  </div>
87
88
  )}
88
89
  <div class={slots.body()}>
@@ -4,7 +4,7 @@
4
4
  "description": "One post in a list (index, archive, related posts): image, category, date, title, excerpt, author. The whole card links to the post.",
5
5
  "category": "content",
6
6
  "uses": [],
7
- "shared": ["KitImage.astro", "image-widths.ts", "safe-url.ts", "types.ts"],
7
+ "shared": ["KitImage.astro", "card-image.ts", "image-widths.ts", "safe-url.ts", "types.ts"],
8
8
  "variants": {
9
9
  "layout": { "options": ["vertical", "horizontal", "minimal"], "default": "vertical", "description": "Image on top, image beside the text, or text only with a divider." },
10
10
  "surface": { "options": ["plain", "card"], "default": "plain", "description": "plain: no surface. card: its own background (--color-card), radius and shadow (--shadow-card), the text padded (--spacing-card) and the image flush at the edge, as theme cards are drawn on a tinted page." },
@@ -7,6 +7,7 @@ import Section from '../_shared/Section.astro'
7
7
  import { instanceId } from '../_shared/instance-id'
8
8
  import { measuredGrid } from '../_shared/measured'
9
9
  import type { MeasuredStyle } from '../_shared/types'
10
+ import { cardImage } from '../_shared/card-image'
10
11
  import { imageOf, type ImageSource } from '../_shared/image'
11
12
  import { profile } from './team'
12
13
  import { safeHref } from '../_shared/safe-url'
@@ -109,7 +110,7 @@ const NameHeading = heading ? 'h3' : 'h2'
109
110
  {items.map(member => (
110
111
  <li class={s.member()}>
111
112
  {/* The portrait's text defaults to the name: a photo of a named person is not decoration. */}
112
- {imageOf(member.image, member.imageAlt ?? member.name) && <div class={`${s.portrait()} kit-portrait`}><KitImage image={imageOf(member.image, member.imageAlt ?? member.name)!} sizes="(min-width: 1024px) 25vw, (min-width: 640px) 50vw, 100vw" class="aspect-square w-full object-cover" /></div>}
113
+ {imageOf(member.image, member.imageAlt ?? member.name) && <div class={`${s.portrait()} kit-portrait`}><KitImage image={cardImage(imageOf(member.image, member.imageAlt ?? member.name), member.name, !!member.href)!} sizes="(min-width: 1024px) 25vw, (min-width: 640px) 50vw, 100vw" class="aspect-square w-full object-cover" /></div>}
113
114
  <NameHeading class={s.name()}>
114
115
  {member.href
115
116
  ? <a href={safeHref(member.href)} class="text-current no-underline after:absolute after:inset-0 after:content-[''] hover:text-accent-text">{member.name}</a>
@@ -7,6 +7,7 @@
7
7
  "shared": [
8
8
  "KitImage.astro",
9
9
  "Section.astro",
10
+ "card-image.ts",
10
11
  "image-widths.ts",
11
12
  "image.ts",
12
13
  "instance-id.ts",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@contentrain/astro-kit",
3
- "version": "0.46.2",
3
+ "version": "0.47.0",
4
4
  "license": "MIT",
5
5
  "description": "Copy-in Astro components for Contentrain sites (shadcn model): typed props, tailwind-variants, zero JavaScript by default — with a machine-readable catalog",
6
6
  "type": "module",