@contentrain/astro-kit 0.2.0 → 0.3.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.
package/README.md CHANGED
@@ -60,6 +60,19 @@ unchanged:
60
60
  - `--spacing-gutter`: the page frame's side padding, which falls back to 1rem
61
61
  on phones and 2rem from `md` up.
62
62
 
63
+ The theme's type scale and rhythm are not read by components. The site's
64
+ theme applies them to the kit's markers, and only when the site sets them:
65
+ - `--text-heading-1` to `--text-heading-3`: headings inside `main`.
66
+ - `--text-body`: `[data-kit-text]`, the lead and body paragraphs of hero, CTA
67
+ and card grid.
68
+ - `--text-nav`: the header navigation, `[data-cr-part="nav-header"]`.
69
+ - `--spacing-section`: the distance between two consecutive sections; each
70
+ `[data-kit-section]` gets half of it as vertical padding, except
71
+ where `data-kit-spacing="none"`.
72
+ - `data-kit-brand` (the site title in Header and Footer) and `data-kit-tagline`
73
+ (Footer) take the theme's own sizes: the title from where it sits (body in
74
+ the header, a heading in the footer), the tagline from its preset.
75
+
63
76
  The full list is in `catalog.json` under `tokens`.
64
77
 
65
78
  ## API
package/catalog.json CHANGED
@@ -20,7 +20,12 @@
20
20
  "--leading-heading",
21
21
  "--radius-control",
22
22
  "--radius-image",
23
- "--spacing-gutter"
23
+ "--spacing-gutter",
24
+ "--text-nav",
25
+ "--text-heading-1",
26
+ "--text-heading-2",
27
+ "--text-heading-3",
28
+ "--spacing-section"
24
29
  ],
25
30
  "dependencies": {
26
31
  "tailwind-merge": "^3.7.0",
@@ -554,10 +559,11 @@
554
559
  "style": {
555
560
  "options": [
556
561
  "lines",
557
- "cards"
562
+ "cards",
563
+ "plain"
558
564
  ],
559
565
  "default": "lines",
560
- "description": "lines: rows divided by rules. cards: each question in a rounded box."
566
+ "description": "lines: rows divided by rules. cards: each question in a rounded box. plain: the browser's own disclosure triangle without rules or boxes (WordPress's details block)."
561
567
  },
562
568
  "tone": {
563
569
  "options": [
@@ -1,6 +1,17 @@
1
1
  ---
2
- // An image through astro:assets when its size is known — optimized and
3
- // sized, so the layout never shifts — and a plain lazy <img> when it is not.
2
+ // An image through astro:assets, optimized and sized so the layout never
3
+ // shifts, from either place a site's media lives:
4
+ //
5
+ // - the repository, under public/ (`/media/2026/01/cover.jpg`): the file is
6
+ // read at build time and gets a srcset of resized copies;
7
+ // - a media host the site allows (Contentrain Studio's delivery URLs, in
8
+ // astro.config `image.remotePatterns`): fetched at build time when its
9
+ // size is known, and resized the same way.
10
+ //
11
+ // Anything else — a vector, a host the site does not allow, a remote image of
12
+ // unknown size — is a plain lazy <img>: nothing is fetched from a host the
13
+ // site did not name.
14
+ import type { ImageMetadata } from 'astro'
4
15
  import { Image } from 'astro:assets'
5
16
  import type { ImageInput } from './types'
6
17
 
@@ -13,9 +24,25 @@ interface Props {
13
24
  priority?: boolean | undefined
14
25
  }
15
26
 
27
+ // Raster files under public/, loaded only when a page shows one.
28
+ const LOCAL = import.meta.glob<ImageMetadata>('/public/**/*.{avif,gif,jpeg,jpg,png,tif,tiff,webp,AVIF,GIF,JPEG,JPG,PNG,TIF,TIFF,WEBP}', { import: 'default' })
29
+
16
30
  const { image, sizes = '100vw', class: className, priority = false } = Astro.props
17
31
  const loading = priority ? 'eager' : 'lazy'
32
+ const fetchpriority = priority ? 'high' : 'auto'
33
+ const path = image.src.startsWith('/') && !image.src.startsWith('//') ? image.src.split(/[?#]/)[0]! : undefined
34
+ // A malformed escape (`%E0%A4%A`) is no file in public/: the image stays a plain <img> rather than failing the build.
35
+ const decoded = (() => { try { return path === undefined ? undefined : decodeURI(path) } catch { return undefined } })()
36
+ const load = decoded === undefined ? undefined : LOCAL[`/public${decoded}`]
37
+ const local = load ? await load() : undefined
38
+ // The stored size (WordPress's chosen size of the file), else the file's own.
39
+ const size = image.width && image.height ? { width: image.width, height: image.height } : {}
40
+ // Candidate widths for the srcset, up to the image's own: the browser picks one by `sizes`.
41
+ const STEPS = [320, 640, 960, 1280, 1600, 1920, 2560]
42
+ const widthsUpTo = (max: number) => [...STEPS.filter(step => step < max), max]
18
43
  ---
19
- {image.width && image.height
20
- ? <Image src={image.src} alt={image.alt} width={image.width} height={image.height} sizes={sizes} class={className} loading={loading} fetchpriority={priority ? 'high' : 'auto'} />
21
- : <img src={image.src} alt={image.alt} class={className} loading={loading} decoding="async" />}
44
+ {local
45
+ ? <Image src={local} alt={image.alt} {...size} widths={widthsUpTo(image.width ?? local.width)} sizes={sizes} class={className} loading={loading} fetchpriority={fetchpriority} />
46
+ : image.width && image.height && path === undefined
47
+ ? <Image src={image.src} alt={image.alt} width={image.width} height={image.height} widths={widthsUpTo(image.width)} sizes={sizes} class={className} loading={loading} fetchpriority={fetchpriority} />
48
+ : <img src={image.src} alt={image.alt} width={image.width} height={image.height} class={className} loading={loading} fetchpriority={fetchpriority} decoding="async" />}
@@ -38,6 +38,6 @@ interface Props {
38
38
 
39
39
  const { tone, spacing, width = 'page', as: Tag = 'section', id, class: className, 'aria-labelledby': labelledBy } = Astro.props
40
40
  ---
41
- <Tag id={id} aria-labelledby={labelledBy} class={section({ tone, spacing, class: className })} data-tone={tone ?? 'default'}>
41
+ <Tag id={id} aria-labelledby={labelledBy} class={section({ tone, spacing, class: className })} data-tone={tone ?? 'default'} data-kit-section data-kit-spacing={spacing ?? 'md'}>
42
42
  {width === 'none' ? <slot /> : <div class={width === 'prose' ? 'container-prose' : 'container-page'}><slot /></div>}
43
43
  </Tag>
@@ -75,7 +75,7 @@ const headingId = title ? `cards-${title.toLowerCase().replace(/[^a-z0-9]+/g, '-
75
75
  ? <a href={item.href} class="text-current no-underline after:absolute after:inset-0 after:content-[''] hover:text-accent">{item.title}</a>
76
76
  : item.title}
77
77
  </CardHeading>
78
- {item.text && <p class={text({ class: dim })}>{item.text}</p>}
78
+ {item.text && <p class={text({ class: dim })} data-kit-text>{item.text}</p>}
79
79
  </li>
80
80
  ))}
81
81
  </ul>
@@ -57,7 +57,7 @@ const shown = actions.map(action => (tone === 'accent' && (action.style ?? 'prim
57
57
  <div class={s.box()}>
58
58
  <div class={s.copy()}>
59
59
  <h2 id={headingId} class={s.heading()}>{heading}</h2>
60
- {body && <p class={s.body()}>{body}</p>}
60
+ {body && <p class={s.body()} data-kit-text>{body}</p>}
61
61
  </div>
62
62
  {shown.length > 0 && <div class={s.actions()}>{shown.map(action => <Button action={action} size="lg" />)}</div>}
63
63
  </div>
@@ -25,6 +25,8 @@ const faq = tv({
25
25
  style: {
26
26
  lines: { list: 'divide-y divide-line border-y border-line' },
27
27
  cards: { list: 'gap-3', item: 'rounded-card border border-line px-5', summary: 'py-5' },
28
+ // WordPress's details block: the browser's own disclosure triangle, no rules or box.
29
+ plain: { list: 'gap-2', summary: 'list-item list-[revert] py-1 text-base [font-weight:inherit] [&::-webkit-details-marker]:inline', marker: 'hidden', answer: 'pt-2 pl-5' },
28
30
  },
29
31
  tone: { default: {}, muted: { item: 'bg-surface' }, inverse: { answer: 'prose-invert' }, accent: { answer: 'prose-invert' } },
30
32
  },
@@ -37,7 +39,7 @@ const faq = tv({
37
39
  interface Props {
38
40
  items: FaqItem[]
39
41
  heading?: string | undefined
40
- style?: 'lines' | 'cards' | undefined
42
+ style?: 'lines' | 'cards' | 'plain' | undefined
41
43
  /** One answer open at a time (`<details name>`). */
42
44
  exclusive?: boolean | undefined
43
45
  /** Open the first answer. */
@@ -32,5 +32,9 @@
32
32
  {
33
33
  "name": "cards-accent",
34
34
  "props": { "style": "cards", "tone": "accent", "id": "faq-accent", "items": [{ "question": "How do I cancel my membership?", "answer": "<p>From your account page, at any time.</p>" }] }
35
+ },
36
+ {
37
+ "name": "plain",
38
+ "props": { "style": "plain", "id": "faq-plain", "items": [{ "question": "Do you accept guest essays?", "answer": "<p>Yes, send a short pitch first.</p>" }, { "question": "How often do you publish?", "answer": "<p>Two or three pieces a month.</p>" }] }
35
39
  }
36
40
  ]
@@ -6,7 +6,7 @@
6
6
  "uses": [],
7
7
  "shared": ["Section.astro"],
8
8
  "variants": {
9
- "style": { "options": ["lines", "cards"], "default": "lines", "description": "lines: rows divided by rules. cards: each question in a rounded box." },
9
+ "style": { "options": ["lines", "cards", "plain"], "default": "lines", "description": "lines: rows divided by rules. cards: each question in a rounded box. plain: the browser's own disclosure triangle without rules or boxes (WordPress's details block)." },
10
10
  "tone": { "options": ["default", "muted", "inverse", "accent"], "default": "default", "description": "Section background." }
11
11
  },
12
12
  "props": {
@@ -48,8 +48,8 @@ const line = copyright ?? `© ${new Date().getFullYear()} ${siteName}`
48
48
  <footer class={root()}>
49
49
  <div class={inner()} style={layout === 'columns' ? `--footer-columns: ${Math.max(columns.length, 1)}` : undefined}>
50
50
  <div class="flex flex-col gap-3">
51
- <p class="text-base font-semibold">{siteName}</p>
52
- {tagline && <p class={muted()}>{tagline}</p>}
51
+ <p class="text-base font-semibold" data-kit-brand>{siteName}</p>
52
+ {tagline && <p class={muted()} data-kit-tagline>{tagline}</p>}
53
53
  {layout === 'columns' && <p class:list={[muted(), 'mt-auto pt-6']}>{line}</p>}
54
54
  </div>
55
55
  {layout === 'columns'
@@ -50,7 +50,7 @@ const { root, inner, brand } = header({ layout, tone, position })
50
50
  ---
51
51
  <header class={root()}>
52
52
  <div class={inner()}>
53
- <a href={homeHref} class={brand()} aria-label={showName ? undefined : siteName}>
53
+ <a href={homeHref} class={brand()} aria-label={showName ? undefined : siteName} data-kit-brand>
54
54
  {logo && <KitImage image={logo} priority class="h-8 w-auto" sizes="160px" />}
55
55
  {showName && <span>{siteName}</span>}
56
56
  </a>
@@ -65,7 +65,7 @@ const cover = layout === 'cover' && image
65
65
  <div class={s.copy()}>
66
66
  {eyebrow && <p class={s.eyebrow()}>{eyebrow}</p>}
67
67
  <Heading id={headingId} class={s.heading()}>{heading}</Heading>
68
- {lead && <p class={s.lead()}>{lead}</p>}
68
+ {lead && <p class={s.lead()} data-kit-text>{lead}</p>}
69
69
  {actions.length > 0 && <div class={s.actions()}>{actions.map(action => <Button action={action} size="lg" />)}</div>}
70
70
  </div>
71
71
  </div>
@@ -78,7 +78,7 @@ const cover = layout === 'cover' && image
78
78
  <div class={s.copy()}>
79
79
  {eyebrow && <p class={s.eyebrow()}>{eyebrow}</p>}
80
80
  <Heading id={headingId} class={s.heading()}>{heading}</Heading>
81
- {lead && <p class={s.lead()}>{lead}</p>}
81
+ {lead && <p class={s.lead()} data-kit-text>{lead}</p>}
82
82
  {actions.length > 0 && <div class={s.actions()}>{actions.map(action => <Button action={action} size="lg" />)}</div>}
83
83
  </div>
84
84
  {image && (
@@ -215,7 +215,8 @@
215
215
  "match": "core/cover",
216
216
  "component": "hero",
217
217
  "variant": {
218
- "layout": "cover"
218
+ "layout": "cover",
219
+ "align": "class:is-position-*-left=start|center"
219
220
  },
220
221
  "props": {
221
222
  "heading": "dom:h1,h2",
@@ -233,6 +234,9 @@
233
234
  {
234
235
  "match": "core/details",
235
236
  "component": "faq",
237
+ "variant": {
238
+ "style": "plain"
239
+ },
236
240
  "group": "siblings",
237
241
  "into": "items",
238
242
  "item": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@contentrain/astro-kit",
3
- "version": "0.2.0",
3
+ "version": "0.3.1",
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.25.0"
45
+ "@contentrain/types": "1.27.0"
46
46
  },
47
47
  "devDependencies": {
48
48
  "@types/node": "^22.10.0",