@contentrain/astro-kit 0.34.2 → 0.35.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
@@ -4566,6 +4566,10 @@
4566
4566
  "type": "string",
4567
4567
  "description": "Locale the date is written in (site.language)."
4568
4568
  },
4569
+ "timeZone": {
4570
+ "type": "string",
4571
+ "description": "Time zone the date is read in (the site's: an IANA name or a ±hh:mm offset); absent, UTC."
4572
+ },
4569
4573
  "image": {
4570
4574
  "type": "object",
4571
4575
  "fields": {
@@ -4804,6 +4808,11 @@
4804
4808
  "content": false,
4805
4809
  "description": "BCP 47 locale the dates are written in (site.language)."
4806
4810
  },
4811
+ "timeZone": {
4812
+ "type": "string",
4813
+ "content": false,
4814
+ "description": "Time zone the dates are read in (the site's: an IANA name or a ±hh:mm offset); absent, UTC."
4815
+ },
4807
4816
  "surface": {
4808
4817
  "type": "string",
4809
4818
  "content": false,
@@ -42,8 +42,14 @@ const image: ImageInput = { ...(measured ? { ...given, ...measured } : given), s
42
42
  const loading = priority ? 'eager' : 'lazy'
43
43
  const fetchpriority = priority ? 'high' : 'auto'
44
44
  const path = image.src.startsWith('/') && !image.src.startsWith('//') ? image.src.split(/[?#]/)[0]! : undefined
45
+ // Under Astro's `base` (a site served from `/blog/`) public/ is served in that directory: a root address is the file's
46
+ // path in public/, printed in the directory. An address already in it is not put there twice.
47
+ const base = (import.meta.env.BASE_URL ?? '/').replace(/\/+$/, '')
48
+ const inBase = (at: string) => base !== '' && (at === base || at.startsWith(`${base}/`))
49
+ const filePath = path !== undefined && inBase(path) ? path.slice(base.length) : path
50
+ const shown = path !== undefined && base !== '' && !inBase(path) ? `${base}${image.src}` : url
45
51
  // A malformed escape (`%E0%A4%A`) is no file in public/: the image stays a plain <img> rather than failing the build.
46
- const decoded = (() => { try { return path === undefined ? undefined : decodeURI(path) } catch { return undefined } })()
52
+ const decoded = (() => { try { return filePath === undefined ? undefined : decodeURI(filePath) } catch { return undefined } })()
47
53
  const load = decoded === undefined ? undefined : LOCAL[`/public${decoded}`]
48
54
  const local = load ? await load() : undefined
49
55
  // The stored size (WordPress's chosen size of the file), else the file's own.
@@ -57,5 +63,5 @@ const widthsUpTo = (max: number) => [...STEPS.filter(step => step < max), max]
57
63
  ? <Image src={local} alt={image.alt ?? ''} {...size} widths={widthsUpTo(image.width ?? local.width)} sizes={sizes} class={className} style={style} loading={loading} fetchpriority={fetchpriority} />
58
64
  : image.width && image.height && path === undefined
59
65
  ? <Image src={url} alt={image.alt ?? ''} width={image.width} height={image.height} widths={widthsUpTo(image.width)} sizes={sizes} class={className} style={style} loading={loading} fetchpriority={fetchpriority} />
60
- : <img src={url} alt={image.alt ?? ''} width={image.width} height={image.height} class={className} style={style} loading={loading} fetchpriority={fetchpriority} decoding="async" />
66
+ : <img src={safeSrc(shown)} alt={image.alt ?? ''} width={image.width} height={image.height} class={className} style={style} loading={loading} fetchpriority={fetchpriority} decoding="async" />
61
67
  )}
@@ -44,6 +44,8 @@ interface Props {
44
44
  date?: Date | string | undefined
45
45
  /** BCP 47 locale the date is written in (site.language). */
46
46
  locale?: string | undefined
47
+ /** The time zone the date is read in (the site's, an IANA name or a `±hh:mm` offset); absent, UTC. */
48
+ timeZone?: string | undefined
47
49
  image?: ImageInput | undefined
48
50
  category?: LinkInput | undefined
49
51
  author?: string | undefined
@@ -63,11 +65,11 @@ interface Props {
63
65
  imageAspect?: string | undefined
64
66
  }
65
67
 
66
- const { title, href, excerpt, date: rawDate, locale = 'en', image, category, author, headingLevel = 2, layout, surface, byline, linkTitle, excerptLines, sizes, imageHeight, imageAspect } = Astro.props
68
+ const { title, href, excerpt, date: rawDate, locale = 'en', timeZone = 'UTC', image, category, author, headingLevel = 2, layout, surface, byline, linkTitle, excerptLines, sizes, imageHeight, imageAspect } = Astro.props
67
69
  const date = rawDate === undefined ? undefined : new Date(rawDate)
68
70
  const slots = card({ layout, surface, byline })
69
71
  const below = byline === 'below'
70
- const shown = date?.toLocaleDateString(locale, { dateStyle: 'long', timeZone: 'UTC' })
72
+ const shown = date?.toLocaleDateString(locale, { dateStyle: 'long', timeZone })
71
73
  const imageStyle = imageHeight ? `height:${imageHeight}` : imageAspect ? `aspect-ratio:${imageAspect}` : undefined
72
74
  const Heading = `h${headingLevel}` as 'h2' | 'h3'
73
75
  ---
@@ -16,6 +16,7 @@
16
16
  "excerpt": { "type": "text", "description": "Summary (posts.excerpt)." },
17
17
  "date": { "type": "datetime", "description": "Publication date (posts.published_at)." },
18
18
  "locale": { "type": "string", "description": "Locale the date is written in (site.language)." },
19
+ "timeZone": { "type": "string", "description": "Time zone the date is read in (the site's: an IANA name or a ±hh:mm offset); absent, UTC." },
19
20
  "image": { "type": "object", "fields": { "src": { "type": "image", "required": true }, "alt": { "type": "string", "required": true }, "width": { "type": "integer" }, "height": { "type": "integer" } }, "description": "Cover (posts.cover → media)." },
20
21
  "category": { "type": "object", "fields": { "label": { "type": "string", "required": true }, "href": { "type": "url", "required": true } }, "description": "Primary category and its archive." },
21
22
  "author": { "type": "string", "description": "Author name (posts.author → authors.name)." },
@@ -61,6 +61,8 @@ interface Props {
61
61
  excerptLines?: 2 | 3 | 4 | undefined
62
62
  /** BCP 47 locale the dates are written in. */
63
63
  locale?: string | undefined
64
+ /** The time zone the dates are read in (the site's: an IANA name or a `±hh:mm` offset); absent, UTC. */
65
+ timeZone?: string | undefined
64
66
  /** Card surface: none, or each post its own card. */
65
67
  surface?: 'plain' | 'card' | undefined
66
68
  /** Where the full archive lives; with `moreLabel` a link follows the list. */
@@ -72,7 +74,7 @@ interface Props {
72
74
  id?: string | undefined
73
75
  }
74
76
 
75
- const { heading, intro, posts, layout = 'grid', columns, showImage = true, showExcerpt = true, showAuthor = true, showCategory = true, excerptLines, locale, surface, moreHref, moreLabel, tone, measured, id = 'posts' } = Astro.props
77
+ const { heading, intro, posts, layout = 'grid', columns, showImage = true, showExcerpt = true, showAuthor = true, showCategory = true, excerptLines, locale, timeZone, surface, moreHref, moreLabel, tone, measured, id = 'posts' } = Astro.props
76
78
  const s = list({ layout, columns })
77
79
  const headingId = heading ? `${id}-title` : undefined
78
80
  // Cards sit one heading level under the section's; with no section heading they are the page's h2s.
@@ -98,6 +100,7 @@ const cardLayout = (i: number) => layout === 'compact' ? 'minimal' : layout ===
98
100
  excerpt={showExcerpt && layout !== 'compact' ? post.excerpt : undefined}
99
101
  date={post.date}
100
102
  locale={locale}
103
+ timeZone={timeZone}
101
104
  image={showImage && layout !== 'compact' ? post.image : undefined}
102
105
  category={showCategory && layout !== 'compact' ? post.category : undefined}
103
106
  author={showAuthor && layout !== 'compact' ? post.author : undefined}
@@ -141,6 +141,11 @@
141
141
  "content": false,
142
142
  "description": "BCP 47 locale the dates are written in (site.language)."
143
143
  },
144
+ "timeZone": {
145
+ "type": "string",
146
+ "content": false,
147
+ "description": "Time zone the dates are read in (the site's: an IANA name or a ±hh:mm offset); absent, UTC."
148
+ },
144
149
  "surface": {
145
150
  "type": "string",
146
151
  "content": false,
@@ -14,7 +14,8 @@ import { type ActionInput, type ImageInput, type MeasuredStyle } from '../_share
14
14
 
15
15
  const slider = tv({
16
16
  slots: {
17
- viewport: 'flex snap-x snap-mandatory gap-4 overflow-x-auto scroll-smooth group-data-[ready]:snap-none group-data-[ready]:overflow-hidden [scrollbar-width:none] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent',
17
+ // A block viewport around the flex row, as Embla's own markup has it: the row is the container Embla moves.
18
+ viewport: 'snap-x snap-mandatory overflow-x-auto scroll-smooth group-data-[ready]:snap-none group-data-[ready]:overflow-hidden [scrollbar-width:none] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent',
18
19
  slide: 'relative min-w-0 shrink-0 snap-start overflow-hidden',
19
20
  image: 'w-full object-cover',
20
21
  caption: 'flex flex-col items-start gap-2',
@@ -140,6 +141,7 @@ const arrow = 'absolute top-1/2 z-10 hidden size-11 -translate-y-1/2 items-cente
140
141
  const prev = root.querySelector<HTMLButtonElement>('[data-prev]')
141
142
  const next = root.querySelector<HTMLButtonElement>('[data-next]')
142
143
  const dots = root.querySelector<HTMLElement>('[data-dots]')
144
+ const track = root.querySelector<HTMLElement>('[data-container]')
143
145
  prev?.addEventListener('click', () => embla.scrollPrev())
144
146
  next?.addEventListener('click', () => embla.scrollNext())
145
147
  // The row takes focus (the buttons are hidden on phones): the arrow keys page it, and a slide change is announced.
@@ -150,10 +152,13 @@ const arrow = 'absolute top-1/2 z-10 hidden size-11 -translate-y-1/2 items-cente
150
152
  else embla.scrollPrev()
151
153
  })
152
154
  // No autoplay: the carousel only moves when asked, so a polite announcement of the slide is right.
153
- root.querySelector('[data-container]')?.setAttribute('aria-live', 'polite')
155
+ track?.setAttribute('aria-live', 'polite')
154
156
 
155
157
  const label = root.dataset.slideLabel ?? 'Slide'
156
- const buttons = embla.scrollSnapList().map((_, index) => {
158
+ // One dot per snap, drawn again whenever Embla measures again: the snaps of a carousel set up before its page was
159
+ // laid out (a background tab, a frame sized later) are not the ones it ends up with.
160
+ let buttons: HTMLButtonElement[] = []
161
+ const drawDots = () => { buttons = embla.scrollSnapList().map((_, index) => {
157
162
  const dot = document.createElement('button')
158
163
  dot.type = 'button'
159
164
  // A 24px target around a 10px dot (WCAG 2.5.8): on phones the dots are the only controls.
@@ -163,16 +168,27 @@ const arrow = 'absolute top-1/2 z-10 hidden size-11 -translate-y-1/2 items-cente
163
168
  dot.append(mark)
164
169
  dot.setAttribute('aria-label', `${label} ${index + 1}`)
165
170
  dot.addEventListener('click', () => embla.scrollTo(index))
166
- dots?.append(dot)
167
171
  return dot
168
172
  })
173
+ dots?.replaceChildren(...buttons) }
174
+ drawDots()
169
175
  const sync = () => {
170
176
  const selected = embla.selectedScrollSnap()
171
177
  buttons.forEach((dot, index) => dot.setAttribute('aria-current', String(index === selected)))
172
178
  if (prev) prev.disabled = !embla.canScrollPrev()
173
179
  if (next) next.disabled = !embla.canScrollNext()
174
180
  }
175
- embla.on('select', sync).on('reInit', sync)
181
+ embla.on('select', sync).on('reInit', () => { drawDots(); sync() })
176
182
  sync()
183
+ // Set up before the layout was final, Embla measures once more when it is: the page loaded, its fonts in, the tab in
184
+ // view. A background tab lays nothing out (ctrl/cmd-click), so its carousel had one snap and a dead "next" until then;
185
+ // Embla's own resize watch does not fire there, since nothing changed size while the tab was hidden. Only a carousel
186
+ // whose row overflows while Embla can page neither way is measured again: one that already pages is left where it is.
187
+ const measure = () => {
188
+ if (track && track.scrollWidth > viewport.clientWidth + 1 && !embla.canScrollNext() && !embla.canScrollPrev()) embla.reInit()
189
+ }
190
+ if (document.readyState !== 'complete') addEventListener('load', measure, { once: true })
191
+ void document.fonts?.ready.then(measure)
192
+ document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'visible') measure() })
177
193
  }
178
194
  </script>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@contentrain/astro-kit",
3
- "version": "0.34.2",
3
+ "version": "0.35.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",
@@ -42,7 +42,7 @@
42
42
  "mapping"
43
43
  ],
44
44
  "dependencies": {
45
- "@contentrain/types": "1.63.0"
45
+ "@contentrain/types": "1.63.1"
46
46
  },
47
47
  "devDependencies": {
48
48
  "@types/node": "^22.10.0",