@contentrain/astro-kit 0.1.1 → 0.3.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/README.md +30 -3
- package/catalog.json +251 -4
- package/components/_shared/Button.astro +1 -1
- package/components/_shared/KitImage.astro +32 -5
- package/components/_shared/Section.astro +1 -1
- package/components/breadcrumb/Breadcrumb.astro +1 -1
- package/components/card-grid/CardGrid.astro +4 -4
- package/components/contact-form/ContactForm.astro +1 -1
- package/components/cta/Cta.astro +2 -2
- package/components/embed/Embed.astro +104 -0
- package/components/embed/embed.ts +106 -0
- package/components/embed/fixtures.json +34 -0
- package/components/embed/meta.json +32 -0
- package/components/faq/Faq.astro +5 -3
- package/components/faq/fixtures.json +4 -0
- package/components/faq/meta.json +1 -1
- package/components/footer/Footer.astro +4 -4
- package/components/footer/meta.json +107 -14
- package/components/gallery/Gallery.astro +3 -3
- package/components/header/Header.astro +1 -1
- package/components/hero/Hero.astro +14 -7
- package/components/hero/fixtures.json +21 -0
- package/components/hero/meta.json +8 -0
- package/components/nav/Nav.astro +4 -2
- package/components/nav/meta.json +86 -13
- package/components/newsletter/Newsletter.astro +110 -0
- package/components/newsletter/fixtures.json +6 -0
- package/components/newsletter/meta.json +105 -0
- package/components/newsletter/newsletter.ts +73 -0
- package/components/pagination/Pagination.astro +1 -1
- package/components/post-card/PostCard.astro +2 -2
- package/components/slider/Slider.astro +3 -3
- package/components/tabs/Tabs.astro +2 -2
- package/components/testimonial/Testimonial.astro +1 -1
- package/dist/index.d.mts +11 -4
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +19 -7
- package/dist/index.mjs.map +1 -1
- package/mapping/classic.json +91 -1
- package/mapping/divi.json +22 -1
- package/mapping/elementor.json +46 -1
- package/mapping/gutenberg.json +70 -4
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -27,6 +27,8 @@ They read its design tokens and pass its gates: `astro check` with the
|
|
|
27
27
|
| `gallery` | Image grid with a popover lightbox | none |
|
|
28
28
|
| `slider` | Carousel on a scroll-snap row | Embla |
|
|
29
29
|
| `contact-form` | Contentrain Studio form in a section | Studio runtime |
|
|
30
|
+
| `newsletter` | Sign-up posting straight to the list provider (Mailchimp, Kit, MailerLite, Brevo, Buttondown) | none |
|
|
31
|
+
| `embed` | Video, map or player loaded on click; a provider link until then (YouTube via nocookie) | a few lines, no dependency |
|
|
30
32
|
| `pagination` | WordPress-style `/page/N/` links | none |
|
|
31
33
|
| `breadcrumb` | Ancestors, then the current page, optional JSON-LD | none |
|
|
32
34
|
|
|
@@ -45,8 +47,30 @@ Components use colour roles and widths, never raw values. A site defines them
|
|
|
45
47
|
in `@theme` (the starter already does): `--color-surface`,
|
|
46
48
|
`--color-surface-muted`, `--color-ink`, `--color-ink-muted`, `--color-line`,
|
|
47
49
|
`--color-accent`, `--color-accent-ink`, `--font-sans`, `--container-prose`,
|
|
48
|
-
`--container-page`, `--container-wide` and `--radius-card`.
|
|
49
|
-
|
|
50
|
+
`--container-page`, `--container-wide` and `--radius-card`.
|
|
51
|
+
|
|
52
|
+
A migrated site also carries its theme's type and shape, and components read
|
|
53
|
+
these with a fallback to the kit's own values, so a site without them looks
|
|
54
|
+
unchanged:
|
|
55
|
+
- `--font-weight-heading`: section headings fall back to 700, item titles to 600.
|
|
56
|
+
- `--font-weight-body`, `--text-body`, `--leading-body` and `--leading-heading`:
|
|
57
|
+
the body and rich-text type. These are in the starter's CSS.
|
|
58
|
+
- `--radius-control`: buttons and form controls.
|
|
59
|
+
- `--radius-image`: images, which fall back to `--radius-card`.
|
|
60
|
+
- `--spacing-gutter`: the page frame's side padding, which falls back to 1rem
|
|
61
|
+
on phones and 2rem from `md` up.
|
|
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
|
+
|
|
73
|
+
The full list is in `catalog.json` under `tokens`.
|
|
50
74
|
|
|
51
75
|
## API
|
|
52
76
|
|
|
@@ -71,7 +95,10 @@ rulesFor(gutenberg, 'core/cover') // → the rule that turns a cover block into
|
|
|
71
95
|
builder elements to components, with a source for each prop from a small closed
|
|
72
96
|
set of expressions (see `src/mapping.ts`). They are versioned, and an element
|
|
73
97
|
no rule covers stays rich text (`fallback: "prose"`). When one rule matches an
|
|
74
|
-
element, the outermost match owns its subtree.
|
|
98
|
+
element, the outermost match owns its subtree. `defaults` lists attribute
|
|
99
|
+
values a builder leaves out of its data while they are at their default
|
|
100
|
+
(Elementor saves no `video_type` for a YouTube video); `when.attr` and `attr:`
|
|
101
|
+
read through them (`attrsOf`).
|
|
75
102
|
|
|
76
103
|
## Development
|
|
77
104
|
|
package/catalog.json
CHANGED
|
@@ -12,7 +12,20 @@
|
|
|
12
12
|
"--container-prose",
|
|
13
13
|
"--container-page",
|
|
14
14
|
"--container-wide",
|
|
15
|
-
"--radius-card"
|
|
15
|
+
"--radius-card",
|
|
16
|
+
"--font-weight-heading",
|
|
17
|
+
"--font-weight-body",
|
|
18
|
+
"--text-body",
|
|
19
|
+
"--leading-body",
|
|
20
|
+
"--leading-heading",
|
|
21
|
+
"--radius-control",
|
|
22
|
+
"--radius-image",
|
|
23
|
+
"--spacing-gutter",
|
|
24
|
+
"--text-nav",
|
|
25
|
+
"--text-heading-1",
|
|
26
|
+
"--text-heading-2",
|
|
27
|
+
"--text-heading-3",
|
|
28
|
+
"--spacing-section"
|
|
16
29
|
],
|
|
17
30
|
"dependencies": {
|
|
18
31
|
"tailwind-merge": "^3.7.0",
|
|
@@ -425,6 +438,114 @@
|
|
|
425
438
|
"Cta.astro"
|
|
426
439
|
]
|
|
427
440
|
},
|
|
441
|
+
{
|
|
442
|
+
"id": "embed",
|
|
443
|
+
"name": "Embed",
|
|
444
|
+
"description": "A video, map or player from another site (YouTube, Vimeo, Google Maps, OpenStreetMap, Spotify, SoundCloud, Calendly, Typeform, Loom, Wistia), loaded only on click: until then a poster link to the provider's page, with no iframe, no third-party request and no cookie (YouTube through youtube-nocookie.com). Unknown providers are never framed; they stay a plain link.",
|
|
445
|
+
"category": "content",
|
|
446
|
+
"uses": [],
|
|
447
|
+
"shared": [
|
|
448
|
+
"KitImage.astro",
|
|
449
|
+
"types.ts"
|
|
450
|
+
],
|
|
451
|
+
"variants": {
|
|
452
|
+
"aspect": {
|
|
453
|
+
"options": [
|
|
454
|
+
"auto",
|
|
455
|
+
"video",
|
|
456
|
+
"portrait",
|
|
457
|
+
"square",
|
|
458
|
+
"map"
|
|
459
|
+
],
|
|
460
|
+
"default": "auto",
|
|
461
|
+
"description": "auto: 16:9 for videos, 9:16 for YouTube Shorts, 4:3 (16:9 wide) for maps, a fixed strip for audio players. The others force a ratio."
|
|
462
|
+
},
|
|
463
|
+
"width": {
|
|
464
|
+
"options": [
|
|
465
|
+
"prose",
|
|
466
|
+
"page"
|
|
467
|
+
],
|
|
468
|
+
"default": "prose",
|
|
469
|
+
"description": "prose: as wide as the reading column. page: the full page frame."
|
|
470
|
+
}
|
|
471
|
+
},
|
|
472
|
+
"props": {
|
|
473
|
+
"url": {
|
|
474
|
+
"type": "url",
|
|
475
|
+
"description": "A video page (youtube.com/watch, youtu.be, /shorts/, vimeo.com/<id>) or a known provider's embed address (google.com/maps/embed, open.spotify.com/embed, w.soundcloud.com/player, calendly.com, *.typeform.com/to, loom.com/embed, fast.wistia.net/embed)."
|
|
476
|
+
},
|
|
477
|
+
"address": {
|
|
478
|
+
"type": "string",
|
|
479
|
+
"description": "A place or address shown on a Google map when there is no url (Elementor google_maps address, Divi map address)."
|
|
480
|
+
},
|
|
481
|
+
"title": {
|
|
482
|
+
"type": "string",
|
|
483
|
+
"description": "What it is: the player's accessible name and the poster's text."
|
|
484
|
+
},
|
|
485
|
+
"caption": {
|
|
486
|
+
"type": "string",
|
|
487
|
+
"description": "A line under the embed."
|
|
488
|
+
},
|
|
489
|
+
"poster": {
|
|
490
|
+
"type": "object",
|
|
491
|
+
"fields": {
|
|
492
|
+
"src": {
|
|
493
|
+
"type": "image",
|
|
494
|
+
"required": true
|
|
495
|
+
},
|
|
496
|
+
"alt": {
|
|
497
|
+
"type": "string",
|
|
498
|
+
"required": true
|
|
499
|
+
},
|
|
500
|
+
"width": {
|
|
501
|
+
"type": "integer"
|
|
502
|
+
},
|
|
503
|
+
"height": {
|
|
504
|
+
"type": "integer"
|
|
505
|
+
}
|
|
506
|
+
},
|
|
507
|
+
"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."
|
|
508
|
+
},
|
|
509
|
+
"playLabel": {
|
|
510
|
+
"type": "string",
|
|
511
|
+
"description": "Poster action for a video or audio player (ui-strings embed.play)."
|
|
512
|
+
},
|
|
513
|
+
"showLabel": {
|
|
514
|
+
"type": "string",
|
|
515
|
+
"description": "Poster action for a map or widget (ui-strings embed.show)."
|
|
516
|
+
},
|
|
517
|
+
"id": {
|
|
518
|
+
"type": "string",
|
|
519
|
+
"description": "Element id."
|
|
520
|
+
}
|
|
521
|
+
},
|
|
522
|
+
"slots": {},
|
|
523
|
+
"js": "vanilla",
|
|
524
|
+
"dependencies": {},
|
|
525
|
+
"sources": {
|
|
526
|
+
"gutenberg": [
|
|
527
|
+
"core/embed"
|
|
528
|
+
],
|
|
529
|
+
"elementor": [
|
|
530
|
+
"elementor/video",
|
|
531
|
+
"elementor/google_maps"
|
|
532
|
+
],
|
|
533
|
+
"divi": [
|
|
534
|
+
"divi/et_pb_video",
|
|
535
|
+
"divi/et_pb_map"
|
|
536
|
+
],
|
|
537
|
+
"classic": [
|
|
538
|
+
"classic/youtube",
|
|
539
|
+
"classic/vimeo",
|
|
540
|
+
"classic/google-maps"
|
|
541
|
+
]
|
|
542
|
+
},
|
|
543
|
+
"a11y": "The poster is one link named '<action>: <title>' that opens the provider's page without script. With script, Enter or a click puts the iframe in its place with the title as its accessible name and moves focus into it. Modified clicks (new tab) keep the link behaviour. The play icon is decorative.",
|
|
544
|
+
"files": [
|
|
545
|
+
"Embed.astro",
|
|
546
|
+
"embed.ts"
|
|
547
|
+
]
|
|
548
|
+
},
|
|
428
549
|
{
|
|
429
550
|
"id": "faq",
|
|
430
551
|
"name": "FAQ",
|
|
@@ -438,10 +559,11 @@
|
|
|
438
559
|
"style": {
|
|
439
560
|
"options": [
|
|
440
561
|
"lines",
|
|
441
|
-
"cards"
|
|
562
|
+
"cards",
|
|
563
|
+
"plain"
|
|
442
564
|
],
|
|
443
565
|
"default": "lines",
|
|
444
|
-
"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)."
|
|
445
567
|
},
|
|
446
568
|
"tone": {
|
|
447
569
|
"options": [
|
|
@@ -567,7 +689,11 @@
|
|
|
567
689
|
"fields": {
|
|
568
690
|
"title": {
|
|
569
691
|
"type": "string",
|
|
570
|
-
"
|
|
692
|
+
"description": "Heading shown above the links; leave out when the source shows none."
|
|
693
|
+
},
|
|
694
|
+
"label": {
|
|
695
|
+
"type": "string",
|
|
696
|
+
"description": "Accessible name of the column navigation (the menu name); defaults to the title."
|
|
571
697
|
},
|
|
572
698
|
"links": {
|
|
573
699
|
"type": "array",
|
|
@@ -956,6 +1082,14 @@
|
|
|
956
1082
|
],
|
|
957
1083
|
"default": "default",
|
|
958
1084
|
"description": "Section background (ignored by cover, which is always light text on the image)."
|
|
1085
|
+
},
|
|
1086
|
+
"align": {
|
|
1087
|
+
"options": [
|
|
1088
|
+
"start",
|
|
1089
|
+
"center"
|
|
1090
|
+
],
|
|
1091
|
+
"default": "center",
|
|
1092
|
+
"description": "Text position over a cover image (layout cover only): centred, as WordPress covers default to, or at the start edge."
|
|
959
1093
|
}
|
|
960
1094
|
},
|
|
961
1095
|
"props": {
|
|
@@ -1131,6 +1265,10 @@
|
|
|
1131
1265
|
"id": {
|
|
1132
1266
|
"type": "string",
|
|
1133
1267
|
"description": "Unique id when a page renders two navigations."
|
|
1268
|
+
},
|
|
1269
|
+
"part": {
|
|
1270
|
+
"type": "string",
|
|
1271
|
+
"description": "What the navigation is, for checks that find a page's parts (data-cr-part), e.g. \"nav-header\"."
|
|
1134
1272
|
}
|
|
1135
1273
|
},
|
|
1136
1274
|
"slots": {},
|
|
@@ -1157,6 +1295,115 @@
|
|
|
1157
1295
|
"Nav.astro"
|
|
1158
1296
|
]
|
|
1159
1297
|
},
|
|
1298
|
+
{
|
|
1299
|
+
"id": "newsletter",
|
|
1300
|
+
"name": "Newsletter sign-up",
|
|
1301
|
+
"description": "A newsletter sign-up that posts straight to the list provider's hosted form endpoint (Mailchimp, Kit, MailerLite, Brevo, Buttondown): a plain HTML form with no script and no server of ours. The provider shows its own confirmation page. The action must be the named provider's endpoint (exact host and path; `other`: https off the site's origin), otherwise nothing renders. WordPress-side sign-up plugins (MC4WP, Divi signup, Jetpack) post through the site and are not mapped here: the plan lists them for review.",
|
|
1302
|
+
"category": "section",
|
|
1303
|
+
"uses": [],
|
|
1304
|
+
"shared": [
|
|
1305
|
+
"Section.astro"
|
|
1306
|
+
],
|
|
1307
|
+
"variants": {
|
|
1308
|
+
"layout": {
|
|
1309
|
+
"options": [
|
|
1310
|
+
"inline",
|
|
1311
|
+
"stacked"
|
|
1312
|
+
],
|
|
1313
|
+
"default": "inline",
|
|
1314
|
+
"description": "inline: copy beside the form on wide screens. stacked: one centred column."
|
|
1315
|
+
},
|
|
1316
|
+
"tone": {
|
|
1317
|
+
"options": [
|
|
1318
|
+
"default",
|
|
1319
|
+
"muted",
|
|
1320
|
+
"inverse",
|
|
1321
|
+
"accent"
|
|
1322
|
+
],
|
|
1323
|
+
"default": "muted",
|
|
1324
|
+
"description": "Section background."
|
|
1325
|
+
}
|
|
1326
|
+
},
|
|
1327
|
+
"props": {
|
|
1328
|
+
"action": {
|
|
1329
|
+
"type": "url",
|
|
1330
|
+
"required": true,
|
|
1331
|
+
"description": "The provider's hosted form endpoint (Mailchimp *.list-manage.com/subscribe/post, Kit app.kit.com/forms/<n>/subscriptions, MailerLite assets.mailerlite.com/jsonp/<n>/forms/<n>/subscribe, Brevo *.sibforms.com/serve/, Buttondown buttondown.com/api/emails/embed-subscribe/). At migration the engine reads it from the source form element (`dom:form@action`) into the section's content, where it stays editable in Studio; it never passes through the fact pack or a model."
|
|
1332
|
+
},
|
|
1333
|
+
"provider": {
|
|
1334
|
+
"type": "select",
|
|
1335
|
+
"options": [
|
|
1336
|
+
"mailchimp",
|
|
1337
|
+
"kit",
|
|
1338
|
+
"mailerlite",
|
|
1339
|
+
"brevo",
|
|
1340
|
+
"buttondown",
|
|
1341
|
+
"other"
|
|
1342
|
+
],
|
|
1343
|
+
"default": "mailchimp",
|
|
1344
|
+
"description": "Names the fields the endpoint expects (Mailchimp EMAIL/FNAME + its bot-trap field, Kit email_address, MailerLite fields[email], Brevo EMAIL, Buttondown email)."
|
|
1345
|
+
},
|
|
1346
|
+
"emailName": {
|
|
1347
|
+
"type": "string",
|
|
1348
|
+
"description": "Field name for the address when the provider is other or names it differently."
|
|
1349
|
+
},
|
|
1350
|
+
"askName": {
|
|
1351
|
+
"type": "boolean",
|
|
1352
|
+
"default": false,
|
|
1353
|
+
"description": "Ask for a first name too, when the provider takes one (the source form had a name field)."
|
|
1354
|
+
},
|
|
1355
|
+
"heading": {
|
|
1356
|
+
"type": "string",
|
|
1357
|
+
"description": "Section heading."
|
|
1358
|
+
},
|
|
1359
|
+
"intro": {
|
|
1360
|
+
"type": "text",
|
|
1361
|
+
"description": "A line or two about the newsletter."
|
|
1362
|
+
},
|
|
1363
|
+
"note": {
|
|
1364
|
+
"type": "string",
|
|
1365
|
+
"description": "Small print under the form (privacy, frequency)."
|
|
1366
|
+
},
|
|
1367
|
+
"emailLabel": {
|
|
1368
|
+
"type": "string",
|
|
1369
|
+
"description": "ui-strings newsletter.email."
|
|
1370
|
+
},
|
|
1371
|
+
"nameLabel": {
|
|
1372
|
+
"type": "string",
|
|
1373
|
+
"description": "ui-strings newsletter.name."
|
|
1374
|
+
},
|
|
1375
|
+
"submitLabel": {
|
|
1376
|
+
"type": "string",
|
|
1377
|
+
"description": "ui-strings newsletter.submit."
|
|
1378
|
+
},
|
|
1379
|
+
"newTab": {
|
|
1380
|
+
"type": "boolean",
|
|
1381
|
+
"default": true,
|
|
1382
|
+
"description": "Open the provider's confirmation page in a new tab (the reader keeps the site)."
|
|
1383
|
+
},
|
|
1384
|
+
"id": {
|
|
1385
|
+
"type": "string",
|
|
1386
|
+
"description": "Section id, unique per page (two sign-ups on one page need two ids): the heading gets `<id>-title`, the inputs `<id>-email`/`<id>-name`."
|
|
1387
|
+
}
|
|
1388
|
+
},
|
|
1389
|
+
"slots": {},
|
|
1390
|
+
"js": "none",
|
|
1391
|
+
"dependencies": {},
|
|
1392
|
+
"sources": {
|
|
1393
|
+
"classic": [
|
|
1394
|
+
"classic/mailchimp",
|
|
1395
|
+
"classic/kit",
|
|
1396
|
+
"classic/mailerlite",
|
|
1397
|
+
"classic/brevo",
|
|
1398
|
+
"classic/buttondown"
|
|
1399
|
+
]
|
|
1400
|
+
},
|
|
1401
|
+
"a11y": "Every input has a visible label and the address field is type=email, required, with autocomplete=email, so the browser validates it and announces errors without script. The form is named by the section heading (or the submit label). Mailchimp's bot-trap field is aria-hidden, off screen and out of the tab order.",
|
|
1402
|
+
"files": [
|
|
1403
|
+
"Newsletter.astro",
|
|
1404
|
+
"newsletter.ts"
|
|
1405
|
+
]
|
|
1406
|
+
},
|
|
1160
1407
|
{
|
|
1161
1408
|
"id": "pagination",
|
|
1162
1409
|
"name": "Pagination",
|
|
@@ -5,7 +5,7 @@ import { tv } from 'tailwind-variants'
|
|
|
5
5
|
import type { ActionInput } from './types'
|
|
6
6
|
|
|
7
7
|
const button = tv({
|
|
8
|
-
base: 'inline-flex items-center justify-center gap-2 rounded-md font-medium no-underline transition-colors focus-visible:outline-2 focus-visible:outline-offset-2',
|
|
8
|
+
base: 'inline-flex items-center justify-center gap-2 rounded-[var(--radius-control,var(--radius-md))] font-medium no-underline transition-colors focus-visible:outline-2 focus-visible:outline-offset-2',
|
|
9
9
|
variants: {
|
|
10
10
|
style: {
|
|
11
11
|
primary: 'bg-accent text-accent-ink hover:bg-ink hover:text-surface',
|
|
@@ -1,6 +1,17 @@
|
|
|
1
1
|
---
|
|
2
|
-
// An image through astro:assets
|
|
3
|
-
//
|
|
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
|
-
{
|
|
20
|
-
? <Image src={
|
|
21
|
-
:
|
|
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>
|
|
@@ -10,8 +10,8 @@ const grid = tv({
|
|
|
10
10
|
slots: {
|
|
11
11
|
list: 'grid items-start gap-6 md:gap-8',
|
|
12
12
|
card: 'group relative flex flex-col gap-3 rounded-[var(--radius-card)]',
|
|
13
|
-
media: 'overflow-hidden rounded-[var(--radius-card)]',
|
|
14
|
-
title: 'text-lg leading-snug font-
|
|
13
|
+
media: 'overflow-hidden rounded-[var(--radius-image,var(--radius-card))]',
|
|
14
|
+
title: 'text-lg leading-snug [font-weight:var(--font-weight-heading,600)] text-balance',
|
|
15
15
|
text: 'opacity-80',
|
|
16
16
|
},
|
|
17
17
|
variants: {
|
|
@@ -61,7 +61,7 @@ const headingId = title ? `cards-${title.toLowerCase().replace(/[^a-z0-9]+/g, '-
|
|
|
61
61
|
<Section tone={tone} aria-labelledby={headingId}>
|
|
62
62
|
{(title || intro) && (
|
|
63
63
|
<header class:list={['mb-10 flex max-w-2xl flex-col gap-3', align === 'center' && 'mx-auto items-center text-center']}>
|
|
64
|
-
{title && <Heading id={headingId} class="text-3xl font-
|
|
64
|
+
{title && <Heading id={headingId} class="text-3xl [font-weight:var(--font-weight-heading,700)] tracking-tight text-balance md:text-4xl">{title}</Heading>}
|
|
65
65
|
{intro && <p class:list={['text-lg', tone === 'accent' ? undefined : 'opacity-80']}>{intro}</p>}
|
|
66
66
|
</header>
|
|
67
67
|
)}
|
|
@@ -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>
|
|
@@ -21,7 +21,7 @@ const contact = tv({
|
|
|
21
21
|
slots: {
|
|
22
22
|
grid: 'grid gap-10',
|
|
23
23
|
copy: 'flex flex-col gap-4',
|
|
24
|
-
heading: 'text-2xl font-
|
|
24
|
+
heading: 'text-2xl [font-weight:var(--font-weight-heading,700)] tracking-tight text-balance md:text-3xl',
|
|
25
25
|
intro: 'max-w-xl text-pretty opacity-85',
|
|
26
26
|
details: 'mt-2 flex flex-col gap-4',
|
|
27
27
|
term: 'text-sm font-semibold opacity-75',
|
package/components/cta/Cta.astro
CHANGED
|
@@ -10,7 +10,7 @@ const cta = tv({
|
|
|
10
10
|
slots: {
|
|
11
11
|
box: 'flex flex-col gap-6',
|
|
12
12
|
copy: 'flex flex-col gap-3',
|
|
13
|
-
heading: 'text-2xl font-
|
|
13
|
+
heading: 'text-2xl [font-weight:var(--font-weight-heading,700)] tracking-tight text-balance md:text-3xl',
|
|
14
14
|
body: 'max-w-2xl text-pretty opacity-85',
|
|
15
15
|
actions: 'flex flex-wrap gap-3',
|
|
16
16
|
},
|
|
@@ -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>
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
---
|
|
2
|
+
// A video, map or player from another site, loaded only when the reader asks
|
|
3
|
+
// for it. Until then it is a link to the provider's page drawn as a poster:
|
|
4
|
+
// no iframe, no third-party request, no cookie (YouTube plays from
|
|
5
|
+
// youtube-nocookie.com). A small script turns the click into the player in
|
|
6
|
+
// place; without it the link simply opens the provider. Unknown providers are
|
|
7
|
+
// never framed — they stay a plain link.
|
|
8
|
+
import { tv } from 'tailwind-variants'
|
|
9
|
+
import KitImage from '../_shared/KitImage.astro'
|
|
10
|
+
import type { ImageInput } from '../_shared/types'
|
|
11
|
+
import { embedTarget, mapTarget } from './embed'
|
|
12
|
+
|
|
13
|
+
const embed = tv({
|
|
14
|
+
slots: {
|
|
15
|
+
root: 'mx-auto my-8 w-full',
|
|
16
|
+
frame: 'relative overflow-hidden rounded-card bg-ink text-surface',
|
|
17
|
+
facade: 'group absolute inset-0 flex items-end justify-start no-underline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent',
|
|
18
|
+
poster: 'absolute inset-0 h-full w-full object-cover opacity-80 transition-opacity group-hover:opacity-65',
|
|
19
|
+
play: 'absolute top-1/2 left-1/2 grid size-16 -translate-x-1/2 -translate-y-1/2 place-items-center rounded-full bg-accent text-accent-ink shadow-lg transition-transform group-hover:scale-105 group-focus-visible:scale-105',
|
|
20
|
+
label: 'relative m-0 w-full bg-gradient-to-t from-ink/90 to-transparent px-4 pt-10 pb-3 text-sm font-semibold text-balance text-surface md:px-5 md:pb-4 md:text-base',
|
|
21
|
+
caption: 'mt-2 text-sm text-ink-muted',
|
|
22
|
+
},
|
|
23
|
+
variants: {
|
|
24
|
+
aspect: {
|
|
25
|
+
auto: {},
|
|
26
|
+
video: { frame: 'aspect-video' },
|
|
27
|
+
portrait: { root: 'max-w-sm', frame: 'aspect-[9/16]' },
|
|
28
|
+
square: { root: 'max-w-xl', frame: 'aspect-square' },
|
|
29
|
+
map: { frame: 'aspect-[4/3] md:aspect-[16/9]' },
|
|
30
|
+
},
|
|
31
|
+
width: { prose: { root: 'max-w-(--container-prose)' }, page: {} },
|
|
32
|
+
},
|
|
33
|
+
defaultVariants: { aspect: 'auto', width: 'prose' },
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
interface Props {
|
|
37
|
+
/** A video page (YouTube, Vimeo) or a known provider's embed address (Google Maps embed, Spotify, SoundCloud, Calendly, Typeform, Loom, Wistia). */
|
|
38
|
+
url?: string | undefined
|
|
39
|
+
/** A place or address to show on a Google map, when there is no `url`. */
|
|
40
|
+
address?: string | undefined
|
|
41
|
+
/** What it is ("Studio tour", "Our office"): the iframe's accessible name and the poster's text. */
|
|
42
|
+
title?: string | undefined
|
|
43
|
+
caption?: string | undefined
|
|
44
|
+
/** Local poster image; without one the poster is a plain dark panel. */
|
|
45
|
+
poster?: ImageInput | undefined
|
|
46
|
+
/** Poster action for a video or player (ui-strings embed.play). */
|
|
47
|
+
playLabel?: string | undefined
|
|
48
|
+
/** Poster action for a map or widget (ui-strings embed.show). */
|
|
49
|
+
showLabel?: string | undefined
|
|
50
|
+
aspect?: 'auto' | 'video' | 'portrait' | 'square' | 'map' | undefined
|
|
51
|
+
width?: 'prose' | 'page' | undefined
|
|
52
|
+
id?: string | undefined
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const { url, address, title, caption, poster, playLabel = 'Play', showLabel = 'Show', aspect = 'auto', width, id } = Astro.props
|
|
56
|
+
const target = (url ? embedTarget(url) : null) ?? (address && !url ? mapTarget(address) : null)
|
|
57
|
+
const shape = aspect !== 'auto' ? aspect : target?.portrait ? 'portrait' : target?.kind === 'map' ? 'map' : 'video'
|
|
58
|
+
// A player strip (Spotify, SoundCloud) has a fixed height, not a ratio.
|
|
59
|
+
const strip = aspect === 'auto' && target?.kind === 'audio'
|
|
60
|
+
const s = embed({ aspect: strip ? 'auto' : shape, width })
|
|
61
|
+
const action = target && (target.kind === 'video' || target.kind === 'audio') ? playLabel : showLabel
|
|
62
|
+
const name = title ?? caption ?? ''
|
|
63
|
+
const linkUrl = url && /^https?:\/\//i.test(url) ? url : undefined
|
|
64
|
+
---
|
|
65
|
+
{target
|
|
66
|
+
? (
|
|
67
|
+
<figure id={id} class={s.root()} data-kit-embed>
|
|
68
|
+
<div class={s.frame({ class: strip ? 'h-[166px]' : undefined })}>
|
|
69
|
+
<a href={target.href} class={s.facade()} data-embed-src={target.src} data-embed-title={name || target.provider} aria-label={name ? `${action}: ${name}` : action} rel="noopener">
|
|
70
|
+
{poster && <KitImage image={{ ...poster, alt: '' }} sizes="(min-width: 768px) 48rem, 100vw" class={s.poster()} />}
|
|
71
|
+
<span class={s.play()} aria-hidden="true">
|
|
72
|
+
{target.kind === 'map'
|
|
73
|
+
? <svg viewBox="0 0 24 24" class="size-7" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 21s-7-6.2-7-11.5a7 7 0 1 1 14 0C19 14.8 12 21 12 21Z" /><circle cx="12" cy="9.5" r="2.5" /></svg>
|
|
74
|
+
: <svg viewBox="0 0 24 24" class="ms-1 size-7" fill="currentColor"><path d="M7 4.5v15a.5.5 0 0 0 .77.42l11.5-7.5a.5.5 0 0 0 0-.84L7.77 4.08A.5.5 0 0 0 7 4.5Z" /></svg>}
|
|
75
|
+
</span>
|
|
76
|
+
<span class={s.label()} aria-hidden="true">{name ? `${action}: ${name}` : action}</span>
|
|
77
|
+
</a>
|
|
78
|
+
</div>
|
|
79
|
+
{caption && <figcaption class={s.caption()}>{caption}</figcaption>}
|
|
80
|
+
</figure>
|
|
81
|
+
)
|
|
82
|
+
: linkUrl && (
|
|
83
|
+
<p id={id} class={s.root()}><a href={linkUrl} rel="noopener" class="underline decoration-line underline-offset-4 hover:decoration-current">{name || linkUrl}</a></p>
|
|
84
|
+
)}
|
|
85
|
+
|
|
86
|
+
<script>
|
|
87
|
+
// Click (or Enter) on the poster: the player replaces it, focused, so keyboard and screen-reader users land in it.
|
|
88
|
+
// A modified click (new tab, new window) keeps the link's own behaviour.
|
|
89
|
+
document.addEventListener('click', (event) => {
|
|
90
|
+
if (event.defaultPrevented || event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return
|
|
91
|
+
const link = (event.target as Element | null)?.closest<HTMLAnchorElement>('[data-kit-embed] a[data-embed-src]')
|
|
92
|
+
if (!link?.dataset.embedSrc) return
|
|
93
|
+
event.preventDefault()
|
|
94
|
+
const frame = document.createElement('iframe')
|
|
95
|
+
frame.src = link.dataset.embedSrc
|
|
96
|
+
frame.title = link.dataset.embedTitle ?? ''
|
|
97
|
+
frame.allow = 'autoplay; encrypted-media; picture-in-picture; fullscreen; clipboard-write'
|
|
98
|
+
frame.allowFullscreen = true
|
|
99
|
+
frame.referrerPolicy = 'strict-origin-when-cross-origin'
|
|
100
|
+
frame.className = 'absolute inset-0 h-full w-full border-0'
|
|
101
|
+
link.replaceWith(frame)
|
|
102
|
+
frame.focus()
|
|
103
|
+
})
|
|
104
|
+
</script>
|