@contentrain/astro-kit 0.1.1 → 0.2.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.
Files changed (39) hide show
  1. package/README.md +20 -3
  2. package/catalog.json +243 -2
  3. package/components/_shared/Button.astro +1 -1
  4. package/components/breadcrumb/Breadcrumb.astro +1 -1
  5. package/components/card-grid/CardGrid.astro +3 -3
  6. package/components/contact-form/ContactForm.astro +1 -1
  7. package/components/cta/Cta.astro +1 -1
  8. package/components/embed/Embed.astro +104 -0
  9. package/components/embed/embed.ts +106 -0
  10. package/components/embed/fixtures.json +34 -0
  11. package/components/embed/meta.json +32 -0
  12. package/components/faq/Faq.astro +2 -2
  13. package/components/footer/Footer.astro +4 -4
  14. package/components/footer/meta.json +107 -14
  15. package/components/gallery/Gallery.astro +3 -3
  16. package/components/header/Header.astro +1 -1
  17. package/components/hero/Hero.astro +12 -5
  18. package/components/hero/fixtures.json +21 -0
  19. package/components/hero/meta.json +8 -0
  20. package/components/nav/Nav.astro +4 -2
  21. package/components/nav/meta.json +86 -13
  22. package/components/newsletter/Newsletter.astro +110 -0
  23. package/components/newsletter/fixtures.json +6 -0
  24. package/components/newsletter/meta.json +105 -0
  25. package/components/newsletter/newsletter.ts +73 -0
  26. package/components/pagination/Pagination.astro +1 -1
  27. package/components/post-card/PostCard.astro +2 -2
  28. package/components/slider/Slider.astro +3 -3
  29. package/components/tabs/Tabs.astro +2 -2
  30. package/components/testimonial/Testimonial.astro +1 -1
  31. package/dist/index.d.mts +11 -4
  32. package/dist/index.d.mts.map +1 -1
  33. package/dist/index.mjs +19 -7
  34. package/dist/index.mjs.map +1 -1
  35. package/mapping/classic.json +91 -1
  36. package/mapping/divi.json +22 -1
  37. package/mapping/elementor.json +46 -1
  38. package/mapping/gutenberg.json +65 -3
  39. 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,20 @@ 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`. The same list is in
49
- `catalog.json` under `tokens`.
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 full list is in `catalog.json` under `tokens`.
50
64
 
51
65
  ## API
52
66
 
@@ -71,7 +85,10 @@ rulesFor(gutenberg, 'core/cover') // → the rule that turns a cover block into
71
85
  builder elements to components, with a source for each prop from a small closed
72
86
  set of expressions (see `src/mapping.ts`). They are versioned, and an element
73
87
  no rule covers stays rich text (`fallback: "prose"`). When one rule matches an
74
- element, the outermost match owns its subtree.
88
+ element, the outermost match owns its subtree. `defaults` lists attribute
89
+ values a builder leaves out of its data while they are at their default
90
+ (Elementor saves no `video_type` for a YouTube video); `when.attr` and `attr:`
91
+ read through them (`attrsOf`).
75
92
 
76
93
  ## Development
77
94
 
package/catalog.json CHANGED
@@ -12,7 +12,15 @@
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"
16
24
  ],
17
25
  "dependencies": {
18
26
  "tailwind-merge": "^3.7.0",
@@ -425,6 +433,114 @@
425
433
  "Cta.astro"
426
434
  ]
427
435
  },
436
+ {
437
+ "id": "embed",
438
+ "name": "Embed",
439
+ "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.",
440
+ "category": "content",
441
+ "uses": [],
442
+ "shared": [
443
+ "KitImage.astro",
444
+ "types.ts"
445
+ ],
446
+ "variants": {
447
+ "aspect": {
448
+ "options": [
449
+ "auto",
450
+ "video",
451
+ "portrait",
452
+ "square",
453
+ "map"
454
+ ],
455
+ "default": "auto",
456
+ "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."
457
+ },
458
+ "width": {
459
+ "options": [
460
+ "prose",
461
+ "page"
462
+ ],
463
+ "default": "prose",
464
+ "description": "prose: as wide as the reading column. page: the full page frame."
465
+ }
466
+ },
467
+ "props": {
468
+ "url": {
469
+ "type": "url",
470
+ "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)."
471
+ },
472
+ "address": {
473
+ "type": "string",
474
+ "description": "A place or address shown on a Google map when there is no url (Elementor google_maps address, Divi map address)."
475
+ },
476
+ "title": {
477
+ "type": "string",
478
+ "description": "What it is: the player's accessible name and the poster's text."
479
+ },
480
+ "caption": {
481
+ "type": "string",
482
+ "description": "A line under the embed."
483
+ },
484
+ "poster": {
485
+ "type": "object",
486
+ "fields": {
487
+ "src": {
488
+ "type": "image",
489
+ "required": true
490
+ },
491
+ "alt": {
492
+ "type": "string",
493
+ "required": true
494
+ },
495
+ "width": {
496
+ "type": "integer"
497
+ },
498
+ "height": {
499
+ "type": "integer"
500
+ }
501
+ },
502
+ "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."
503
+ },
504
+ "playLabel": {
505
+ "type": "string",
506
+ "description": "Poster action for a video or audio player (ui-strings embed.play)."
507
+ },
508
+ "showLabel": {
509
+ "type": "string",
510
+ "description": "Poster action for a map or widget (ui-strings embed.show)."
511
+ },
512
+ "id": {
513
+ "type": "string",
514
+ "description": "Element id."
515
+ }
516
+ },
517
+ "slots": {},
518
+ "js": "vanilla",
519
+ "dependencies": {},
520
+ "sources": {
521
+ "gutenberg": [
522
+ "core/embed"
523
+ ],
524
+ "elementor": [
525
+ "elementor/video",
526
+ "elementor/google_maps"
527
+ ],
528
+ "divi": [
529
+ "divi/et_pb_video",
530
+ "divi/et_pb_map"
531
+ ],
532
+ "classic": [
533
+ "classic/youtube",
534
+ "classic/vimeo",
535
+ "classic/google-maps"
536
+ ]
537
+ },
538
+ "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.",
539
+ "files": [
540
+ "Embed.astro",
541
+ "embed.ts"
542
+ ]
543
+ },
428
544
  {
429
545
  "id": "faq",
430
546
  "name": "FAQ",
@@ -567,7 +683,11 @@
567
683
  "fields": {
568
684
  "title": {
569
685
  "type": "string",
570
- "required": true
686
+ "description": "Heading shown above the links; leave out when the source shows none."
687
+ },
688
+ "label": {
689
+ "type": "string",
690
+ "description": "Accessible name of the column navigation (the menu name); defaults to the title."
571
691
  },
572
692
  "links": {
573
693
  "type": "array",
@@ -956,6 +1076,14 @@
956
1076
  ],
957
1077
  "default": "default",
958
1078
  "description": "Section background (ignored by cover, which is always light text on the image)."
1079
+ },
1080
+ "align": {
1081
+ "options": [
1082
+ "start",
1083
+ "center"
1084
+ ],
1085
+ "default": "center",
1086
+ "description": "Text position over a cover image (layout cover only): centred, as WordPress covers default to, or at the start edge."
959
1087
  }
960
1088
  },
961
1089
  "props": {
@@ -1131,6 +1259,10 @@
1131
1259
  "id": {
1132
1260
  "type": "string",
1133
1261
  "description": "Unique id when a page renders two navigations."
1262
+ },
1263
+ "part": {
1264
+ "type": "string",
1265
+ "description": "What the navigation is, for checks that find a page's parts (data-cr-part), e.g. \"nav-header\"."
1134
1266
  }
1135
1267
  },
1136
1268
  "slots": {},
@@ -1157,6 +1289,115 @@
1157
1289
  "Nav.astro"
1158
1290
  ]
1159
1291
  },
1292
+ {
1293
+ "id": "newsletter",
1294
+ "name": "Newsletter sign-up",
1295
+ "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.",
1296
+ "category": "section",
1297
+ "uses": [],
1298
+ "shared": [
1299
+ "Section.astro"
1300
+ ],
1301
+ "variants": {
1302
+ "layout": {
1303
+ "options": [
1304
+ "inline",
1305
+ "stacked"
1306
+ ],
1307
+ "default": "inline",
1308
+ "description": "inline: copy beside the form on wide screens. stacked: one centred column."
1309
+ },
1310
+ "tone": {
1311
+ "options": [
1312
+ "default",
1313
+ "muted",
1314
+ "inverse",
1315
+ "accent"
1316
+ ],
1317
+ "default": "muted",
1318
+ "description": "Section background."
1319
+ }
1320
+ },
1321
+ "props": {
1322
+ "action": {
1323
+ "type": "url",
1324
+ "required": true,
1325
+ "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."
1326
+ },
1327
+ "provider": {
1328
+ "type": "select",
1329
+ "options": [
1330
+ "mailchimp",
1331
+ "kit",
1332
+ "mailerlite",
1333
+ "brevo",
1334
+ "buttondown",
1335
+ "other"
1336
+ ],
1337
+ "default": "mailchimp",
1338
+ "description": "Names the fields the endpoint expects (Mailchimp EMAIL/FNAME + its bot-trap field, Kit email_address, MailerLite fields[email], Brevo EMAIL, Buttondown email)."
1339
+ },
1340
+ "emailName": {
1341
+ "type": "string",
1342
+ "description": "Field name for the address when the provider is other or names it differently."
1343
+ },
1344
+ "askName": {
1345
+ "type": "boolean",
1346
+ "default": false,
1347
+ "description": "Ask for a first name too, when the provider takes one (the source form had a name field)."
1348
+ },
1349
+ "heading": {
1350
+ "type": "string",
1351
+ "description": "Section heading."
1352
+ },
1353
+ "intro": {
1354
+ "type": "text",
1355
+ "description": "A line or two about the newsletter."
1356
+ },
1357
+ "note": {
1358
+ "type": "string",
1359
+ "description": "Small print under the form (privacy, frequency)."
1360
+ },
1361
+ "emailLabel": {
1362
+ "type": "string",
1363
+ "description": "ui-strings newsletter.email."
1364
+ },
1365
+ "nameLabel": {
1366
+ "type": "string",
1367
+ "description": "ui-strings newsletter.name."
1368
+ },
1369
+ "submitLabel": {
1370
+ "type": "string",
1371
+ "description": "ui-strings newsletter.submit."
1372
+ },
1373
+ "newTab": {
1374
+ "type": "boolean",
1375
+ "default": true,
1376
+ "description": "Open the provider's confirmation page in a new tab (the reader keeps the site)."
1377
+ },
1378
+ "id": {
1379
+ "type": "string",
1380
+ "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`."
1381
+ }
1382
+ },
1383
+ "slots": {},
1384
+ "js": "none",
1385
+ "dependencies": {},
1386
+ "sources": {
1387
+ "classic": [
1388
+ "classic/mailchimp",
1389
+ "classic/kit",
1390
+ "classic/mailerlite",
1391
+ "classic/brevo",
1392
+ "classic/buttondown"
1393
+ ]
1394
+ },
1395
+ "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.",
1396
+ "files": [
1397
+ "Newsletter.astro",
1398
+ "newsletter.ts"
1399
+ ]
1400
+ },
1160
1401
  {
1161
1402
  "id": "pagination",
1162
1403
  "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',
@@ -42,7 +42,7 @@ const jsonLd = schema
42
42
  }).replace(/</g, '\\u003c')
43
43
  : undefined
44
44
  ---
45
- <nav aria-label={label}>
45
+ <nav aria-label={label} data-cr-part="breadcrumb">
46
46
  <ol class={slots.list()}>
47
47
  {items.map(item => (
48
48
  <li class="flex items-center gap-2">
@@ -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-semibold text-balance',
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-bold tracking-tight text-balance md:text-4xl">{title}</Heading>}
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
  )}
@@ -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-bold tracking-tight text-balance md:text-3xl',
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',
@@ -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-bold tracking-tight text-balance md:text-3xl',
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
  },
@@ -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>
@@ -0,0 +1,106 @@
1
+ // What an embed URL becomes: the player/map address the iframe loads after a
2
+ // click, the page a plain link opens without JavaScript, and the provider.
3
+ // Only known providers get an iframe; anything else stays a link, so a page
4
+ // can never frame an arbitrary address that came in with the content.
5
+
6
+ export type EmbedProvider = 'youtube' | 'vimeo' | 'google-maps' | 'openstreetmap' | 'spotify' | 'soundcloud' | 'calendly' | 'typeform' | 'loom' | 'wistia'
7
+
8
+ export interface EmbedTarget {
9
+ provider: EmbedProvider
10
+ /** Loaded into the iframe on activation (autoplays where the provider allows it: the click was the consent). */
11
+ src: string
12
+ /** Opened by the plain link: the provider's own page. */
13
+ href: string
14
+ kind: 'video' | 'map' | 'audio' | 'widget'
15
+ /** YouTube Shorts and other vertical players. */
16
+ portrait?: boolean
17
+ }
18
+
19
+ const https = (value: string): URL | null => {
20
+ try {
21
+ const url = new URL(value.trim())
22
+ return url.protocol === 'https:' || url.protocol === 'http:' ? url : null
23
+ }
24
+ catch { return null }
25
+ }
26
+
27
+ const YOUTUBE_ID = /^[\w-]{11}$/
28
+ /** `t=1m30s`, `t=90`, `start=90` → seconds. */
29
+ const seconds = (value: string | null): number => {
30
+ if (!value) return 0
31
+ if (/^\d+$/.test(value)) return Number(value)
32
+ const m = /^(?:(\d+)h)?(?:(\d+)m)?(?:(\d+)s)?$/.exec(value)
33
+ return m ? Number(m[1] ?? 0) * 3600 + Number(m[2] ?? 0) * 60 + Number(m[3] ?? 0) : 0
34
+ }
35
+
36
+ function youtube(url: URL): EmbedTarget | null {
37
+ const host = url.hostname.replace(/^(?:www|m)\./, '')
38
+ let id: string | null = null
39
+ let portrait = false
40
+ if (host === 'youtu.be') id = url.pathname.slice(1).split('/')[0] ?? null
41
+ else if (host === 'youtube.com' || host === 'youtube-nocookie.com') {
42
+ const [first, second] = url.pathname.split('/').filter(Boolean)
43
+ if (first === 'watch') id = url.searchParams.get('v')
44
+ else if (first === 'embed' || first === 'live' || first === 'v') id = second ?? null
45
+ else if (first === 'shorts') { id = second ?? null; portrait = true }
46
+ }
47
+ if (!id || !YOUTUBE_ID.test(id)) return null
48
+ const start = seconds(url.searchParams.get('t') ?? url.searchParams.get('start'))
49
+ const query = new URLSearchParams({ autoplay: '1', rel: '0' })
50
+ if (start) query.set('start', String(start))
51
+ return {
52
+ provider: 'youtube',
53
+ kind: 'video',
54
+ src: `https://www.youtube-nocookie.com/embed/${id}?${query}`,
55
+ href: portrait ? `https://www.youtube.com/shorts/${id}` : `https://www.youtube.com/watch?v=${id}${start ? `&t=${start}s` : ''}`,
56
+ ...(portrait ? { portrait } : {}),
57
+ }
58
+ }
59
+
60
+ function vimeo(url: URL): EmbedTarget | null {
61
+ const host = url.hostname.replace(/^www\./, '')
62
+ if (host !== 'vimeo.com' && host !== 'player.vimeo.com') return null
63
+ const parts = url.pathname.split('/').filter(Boolean)
64
+ const at = parts[0] === 'video' ? 1 : parts.findIndex(p => /^\d+$/.test(p))
65
+ const id = parts[at]
66
+ if (!id || !/^\d+$/.test(id)) return null
67
+ // Unlisted videos carry a hash: `vimeo.com/123/abcdef` or `?h=abcdef`.
68
+ const hash = url.searchParams.get('h') ?? (parts[at + 1] && /^[\da-f]+$/i.test(parts[at + 1]!) ? parts[at + 1]! : null)
69
+ const query = new URLSearchParams({ autoplay: '1', dnt: '1' })
70
+ if (hash) query.set('h', hash)
71
+ return { provider: 'vimeo', kind: 'video', src: `https://player.vimeo.com/video/${id}?${query}`, href: `https://vimeo.com/${id}${hash ? `/${hash}` : ''}` }
72
+ }
73
+
74
+ const GOOGLE = new Set(['www.google.com', 'google.com'])
75
+
76
+ /** Framed as given: the host is known to serve an embeddable page at that address. */
77
+ const FRAMED: Array<[EmbedProvider, EmbedTarget['kind'], (url: URL) => boolean]> = [
78
+ // Exact hosts only: a pattern over Google's country domains also admits look-alikes (`google.com.evil.example`).
79
+ ['google-maps', 'map', u => (GOOGLE.has(u.hostname) && u.pathname.startsWith('/maps/embed')) || (u.hostname === 'maps.google.com' && u.searchParams.get('output') === 'embed')],
80
+ ['openstreetmap', 'map', u => u.hostname === 'www.openstreetmap.org' && u.pathname === '/export/embed.html'],
81
+ ['spotify', 'audio', u => u.hostname === 'open.spotify.com' && u.pathname.startsWith('/embed/')],
82
+ ['soundcloud', 'audio', u => u.hostname === 'w.soundcloud.com' && u.pathname.startsWith('/player')],
83
+ ['calendly', 'widget', u => u.hostname === 'calendly.com'],
84
+ ['typeform', 'widget', u => /^[\w-]+\.typeform\.com$/.test(u.hostname) && u.pathname.startsWith('/to/')],
85
+ ['loom', 'video', u => /^(?:www\.)?loom\.com$/.test(u.hostname) && u.pathname.startsWith('/embed/')],
86
+ ['wistia', 'video', u => u.hostname === 'fast.wistia.net' && u.pathname.startsWith('/embed/')],
87
+ ]
88
+
89
+ /** A video page or player URL, or a known provider's embed address. `null` = not embeddable. */
90
+ export function embedTarget(value: string): EmbedTarget | null {
91
+ const url = https(value)
92
+ if (!url) return null
93
+ const found = youtube(url) ?? vimeo(url)
94
+ if (found) return found
95
+ url.protocol = 'https:'
96
+ for (const [provider, kind, test] of FRAMED) if (test(url)) return { provider, kind, src: url.href, href: url.href }
97
+ return null
98
+ }
99
+
100
+ /** A Google map of a place or address (what Elementor's and Divi's map widgets store). */
101
+ export function mapTarget(address: string): EmbedTarget | null {
102
+ const q = address.trim()
103
+ if (!q) return null
104
+ const query = encodeURIComponent(q)
105
+ return { provider: 'google-maps', kind: 'map', src: `https://www.google.com/maps?q=${query}&output=embed`, href: `https://www.google.com/maps/search/?api=1&query=${query}` }
106
+ }
@@ -0,0 +1,34 @@
1
+ [
2
+ {
3
+ "name": "youtube-poster",
4
+ "props": { "url": "https://www.youtube.com/watch?v=aqz-KE-bpKQ&t=1m5s", "title": "A walk through the new studio", "caption": "Filmed in spring, four minutes.", "poster": { "src": "/kit-fixtures/photo-2.svg", "alt": "", "width": 1200, "height": 800 } }
5
+ },
6
+ {
7
+ "name": "vimeo-plain-page",
8
+ "props": { "url": "https://vimeo.com/76979871", "title": "The making of the journal", "width": "page", "aspect": "video" }
9
+ },
10
+ {
11
+ "name": "shorts-auto-portrait",
12
+ "props": { "url": "https://youtube.com/shorts/aqz-KE-bpKQ", "title": "One-minute tip" }
13
+ },
14
+ {
15
+ "name": "forced-square",
16
+ "props": { "url": "https://youtu.be/aqz-KE-bpKQ", "title": "Square cut", "aspect": "square" }
17
+ },
18
+ {
19
+ "name": "forced-portrait",
20
+ "props": { "url": "https://youtu.be/aqz-KE-bpKQ", "title": "Vertical cut", "aspect": "portrait" }
21
+ },
22
+ {
23
+ "name": "map-address",
24
+ "props": { "address": "Rua Augusta 100, Lisbon", "title": "Our office", "showLabel": "Show map", "aspect": "map" }
25
+ },
26
+ {
27
+ "name": "spotify-strip",
28
+ "props": { "url": "https://open.spotify.com/embed/episode/7makk4oTQel546B0PZlDM5", "title": "Episode 12 — Slow travel" }
29
+ },
30
+ {
31
+ "name": "unknown-provider-link",
32
+ "props": { "url": "https://example.com/widget/42", "title": "Booking calendar" }
33
+ }
34
+ ]