@contentrain/astro-kit 0.32.0 → 0.33.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.
Files changed (85) hide show
  1. package/catalog.json +850 -19
  2. package/components/_shared/Button.astro +2 -1
  3. package/components/_shared/KitImage.astro +9 -4
  4. package/components/_shared/Section.astro +3 -1
  5. package/components/_shared/SectionHeader.astro +27 -0
  6. package/components/_shared/measured.ts +13 -0
  7. package/components/_shared/safe-url.ts +67 -0
  8. package/components/_shared/social.ts +73 -0
  9. package/components/announcement/Announcement.astro +2 -1
  10. package/components/announcement/meta.json +1 -1
  11. package/components/band/Band.astro +3 -2
  12. package/components/band/meta.json +1 -0
  13. package/components/breadcrumb/Breadcrumb.astro +2 -1
  14. package/components/breadcrumb/meta.json +1 -1
  15. package/components/card-grid/CardGrid.astro +2 -1
  16. package/components/card-grid/meta.json +1 -0
  17. package/components/consent/Consent.astro +2 -1
  18. package/components/consent/meta.json +1 -1
  19. package/components/contact-details/ContactDetails.astro +3 -2
  20. package/components/contact-details/meta.json +1 -0
  21. package/components/contact-form/ContactForm.astro +4 -3
  22. package/components/contact-form/meta.json +1 -0
  23. package/components/cta/meta.json +1 -1
  24. package/components/dialog/Dialog.astro +113 -0
  25. package/components/dialog/fixtures.json +84 -0
  26. package/components/dialog/meta.json +252 -0
  27. package/components/embed/meta.json +1 -1
  28. package/components/faq/fixtures.json +4 -0
  29. package/components/faq/meta.json +2 -2
  30. package/components/feature-list/meta.json +1 -0
  31. package/components/footer/Footer.astro +5 -4
  32. package/components/footer/meta.json +1 -0
  33. package/components/gallery/Gallery.astro +2 -1
  34. package/components/gallery/meta.json +1 -0
  35. package/components/header/Header.astro +17 -5
  36. package/components/header/fixtures.json +90 -1
  37. package/components/header/meta.json +5 -3
  38. package/components/hero/meta.json +1 -0
  39. package/components/language-switcher/LanguageSwitcher.astro +68 -0
  40. package/components/language-switcher/fixtures.json +51 -0
  41. package/components/language-switcher/meta.json +73 -0
  42. package/components/logo-cloud/LogoCloud.astro +2 -1
  43. package/components/logo-cloud/meta.json +1 -0
  44. package/components/nav/Nav.astro +77 -24
  45. package/components/nav/NavTree.astro +40 -0
  46. package/components/nav/fixtures.json +165 -0
  47. package/components/nav/meta.json +11 -2
  48. package/components/newsletter/Newsletter.astro +2 -1
  49. package/components/newsletter/meta.json +1 -0
  50. package/components/pagination/Pagination.astro +4 -3
  51. package/components/pagination/meta.json +1 -1
  52. package/components/post-card/PostCard.astro +4 -3
  53. package/components/post-card/meta.json +1 -1
  54. package/components/post-list/PostList.astro +3 -2
  55. package/components/post-list/meta.json +1 -0
  56. package/components/pricing/meta.json +1 -0
  57. package/components/search-box/SearchBox.astro +51 -0
  58. package/components/search-box/fixtures.json +31 -0
  59. package/components/search-box/meta.json +75 -0
  60. package/components/slider/Slider.astro +19 -115
  61. package/components/slider/SliderTrack.astro +178 -0
  62. package/components/slider/fixtures.json +2 -1
  63. package/components/slider/meta.json +11 -5
  64. package/components/social-links/SocialLinks.astro +74 -0
  65. package/components/social-links/fixtures.json +67 -0
  66. package/components/social-links/meta.json +94 -0
  67. package/components/split/meta.json +1 -0
  68. package/components/stats/Stats.astro +19 -8
  69. package/components/stats/fixtures.json +87 -0
  70. package/components/stats/meta.json +1 -0
  71. package/components/steps/meta.json +1 -0
  72. package/components/tabs/Tabs.astro +10 -3
  73. package/components/tabs/fixtures.json +4 -0
  74. package/components/tabs/meta.json +5 -2
  75. package/components/team/Team.astro +3 -2
  76. package/components/team/meta.json +2 -0
  77. package/components/team/team.ts +3 -8
  78. package/components/testimonial/meta.json +2 -3
  79. package/dist/index.mjs +1 -1
  80. package/dist/index.mjs.map +1 -1
  81. package/mapping/classic.json +117 -2
  82. package/mapping/divi.json +6 -5
  83. package/mapping/elementor.json +120 -7
  84. package/mapping/gutenberg.json +20 -8
  85. package/package.json +2 -2
@@ -3,6 +3,7 @@
3
3
  // migrated content are links, so this is always an <a>.
4
4
  import { tv } from 'tailwind-variants'
5
5
  import type { ActionInput } from './types'
6
+ import { safeHref } from './safe-url'
6
7
 
7
8
  const button = tv({
8
9
  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',
@@ -30,7 +31,7 @@ interface Props {
30
31
  const { action, size, class: className } = Astro.props
31
32
  ---
32
33
  <a
33
- href={action.href}
34
+ href={safeHref(action.href)}
34
35
  target={action.newTab ? '_blank' : undefined}
35
36
  rel={action.newTab ? 'noopener' : undefined}
36
37
  class={button({ style: action.style ?? 'primary', size, class: className })}
@@ -16,6 +16,7 @@
16
16
  import type { ImageMetadata } from 'astro'
17
17
  import { Image } from 'astro:assets'
18
18
  import { mediaSize } from '../../../lib/media'
19
+ import { safeSrc } from './safe-url'
19
20
  import type { ImageInput } from './types'
20
21
 
21
22
  interface Props {
@@ -35,7 +36,9 @@ const LOCAL = import.meta.glob<ImageMetadata>('/public/**/*.{avif,gif,jpeg,jpg,p
35
36
  const { image: given, sizes = '100vw', class: className, style, priority = false } = Astro.props
36
37
  // Content stores an address; the size the migration measured comes from the site's media table.
37
38
  const measured = given.width && given.height ? undefined : mediaSize(given.src)
38
- const image: ImageInput = measured ? { ...given, ...measured } : given
39
+ // An address that is not a plain image link (`javascript:`, `data:text/html`) draws nothing.
40
+ const url = safeSrc(given.src)
41
+ const image: ImageInput = { ...(measured ? { ...given, ...measured } : given), src: url ?? '' }
39
42
  const loading = priority ? 'eager' : 'lazy'
40
43
  const fetchpriority = priority ? 'high' : 'auto'
41
44
  const path = image.src.startsWith('/') && !image.src.startsWith('//') ? image.src.split(/[?#]/)[0]! : undefined
@@ -49,8 +52,10 @@ const size = image.width && image.height ? { width: image.width, height: image.h
49
52
  const STEPS = [320, 640, 960, 1280, 1600, 1920, 2560]
50
53
  const widthsUpTo = (max: number) => [...STEPS.filter(step => step < max), max]
51
54
  ---
52
- {local
55
+ {url && (
56
+ local
53
57
  ? <Image src={local} alt={image.alt ?? ''} {...size} widths={widthsUpTo(image.width ?? local.width)} sizes={sizes} class={className} style={style} loading={loading} fetchpriority={fetchpriority} />
54
58
  : image.width && image.height && path === undefined
55
- ? <Image src={image.src} alt={image.alt ?? ''} width={image.width} height={image.height} widths={widthsUpTo(image.width)} sizes={sizes} class={className} style={style} loading={loading} fetchpriority={fetchpriority} />
56
- : <img src={image.src} alt={image.alt ?? ''} width={image.width} height={image.height} class={className} style={style} loading={loading} fetchpriority={fetchpriority} decoding="async" />}
59
+ ? <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" />
61
+ )}
@@ -4,6 +4,7 @@
4
4
  // their order.
5
5
  import { tv } from 'tailwind-variants'
6
6
  import { measuredFrame } from './measured'
7
+ import { safeSrc } from './safe-url'
7
8
  import type { MeasuredStyle } from './types'
8
9
 
9
10
  const section = tv({
@@ -54,10 +55,11 @@ const layered = m.image || m.overlay ? 'relative isolate overflow-hidden' : unde
54
55
  const style = [m.color && `background-color: ${m.color}`, m.style].filter(Boolean).join('; ') || undefined
55
56
  // The reading column reads its own width token.
56
57
  const frameStyle = width === 'prose' ? m.frameStyle?.replace('--container-page:', '--container-prose:') : m.frameStyle
58
+ const backdrop = safeSrc(m.image?.src)
57
59
  const dataTone = m.color ? (m.tone === 'dark' ? 'inverse' : 'default') : tone ?? 'default'
58
60
  ---
59
61
  <Tag id={id} aria-labelledby={labelledBy} class={section({ tone, spacing, class: [text, layered, className].filter(Boolean).join(' ') || undefined })} style={style} data-tone={dataTone} data-kit-section data-kit-spacing={spacing ?? 'md'} data-kit-measured={measured ? '' : undefined}>
60
- {m.image && <img src={m.image.src} alt="" loading="lazy" decoding="async" class="absolute inset-0 -z-10 h-full w-full" style={`object-fit: ${m.image.fit}${m.image.position ? `; object-position: ${m.image.position}` : ''}`} />}
62
+ {m.image && backdrop && <img src={backdrop} alt="" loading="lazy" decoding="async" class="absolute inset-0 -z-10 h-full w-full" style={`object-fit: ${m.image.fit}${m.image.position ? `; object-position: ${m.image.position}` : ''}`} />}
61
63
  {m.overlay && <div class="absolute inset-0 -z-10" style={`background: ${m.overlay}`} aria-hidden="true" />}
62
64
  {width === 'none' ? <slot /> : <div class={width === 'prose' ? 'container-prose' : 'container-page'} style={frameStyle}><slot /></div>}
63
65
  </Tag>
@@ -0,0 +1,27 @@
1
+ ---
2
+ // The head of a section: an optional eyebrow, heading, intro and buttons, in the order a reader meets them. The
3
+ // section components that have no header of their own use it; with none of the four given it draws nothing.
4
+ import Button from './Button.astro'
5
+ import type { ActionInput } from './types'
6
+
7
+ interface Props {
8
+ heading?: string | undefined
9
+ intro?: string | undefined
10
+ eyebrow?: string | undefined
11
+ actions?: ActionInput[] | undefined
12
+ /** The heading's `id`, for the section's `aria-labelledby`. */
13
+ headingId?: string | undefined
14
+ headingLevel?: 2 | 3 | undefined
15
+ }
16
+
17
+ const { heading, intro, eyebrow, actions = [], headingId, headingLevel = 2 } = Astro.props
18
+ const Heading = `h${headingLevel}` as 'h2' | 'h3'
19
+ ---
20
+ {(heading || intro || eyebrow || actions.length > 0) && (
21
+ <header class="mb-10 flex max-w-2xl flex-col gap-3">
22
+ {eyebrow && <p class="text-sm font-semibold tracking-wide uppercase opacity-80">{eyebrow}</p>}
23
+ {heading && <Heading id={headingId} class="text-3xl [font-weight:var(--font-weight-heading,700)] tracking-tight text-balance md:text-4xl">{heading}</Heading>}
24
+ {intro && <p class="text-lg opacity-80" data-kit-text>{intro}</p>}
25
+ {actions.length > 0 && <div class="mt-2 flex flex-wrap gap-3">{actions.map(action => <Button action={action} />)}</div>}
26
+ </header>
27
+ )}
@@ -150,3 +150,16 @@ export function measuredColumns(measured: MeasuredStyle | undefined): string | u
150
150
  const gap = clamp(measured.gap, 0, 200) !== undefined ? 'gap-x-[var(--kit-gap)]' : ''
151
151
  return ['block columns-[var(--kit-cols-390)] md:columns-[var(--kit-cols-768)] lg:columns-[var(--kit-cols-1280)]', gap].filter(Boolean).join(' ')
152
152
  }
153
+
154
+ /**
155
+ * A row of slides at the measured columns (slides in view at 390, 768 and 1280) and gap, or `undefined` to keep the
156
+ * component's own `perView`. A basis per width: the row less the gaps, shared by the columns. The class names are written
157
+ * out in full so Tailwind finds them.
158
+ */
159
+ export function measuredSlides(measured: MeasuredStyle | undefined): { slide: string, track: string } | undefined {
160
+ if (!measured || !columnsFor(measured.columns)) return undefined
161
+ return {
162
+ slide: 'basis-[calc((100%-(var(--kit-cols-390)-1)*var(--kit-gap,1rem))/var(--kit-cols-390))] md:basis-[calc((100%-(var(--kit-cols-768)-1)*var(--kit-gap,1rem))/var(--kit-cols-768))] lg:basis-[calc((100%-(var(--kit-cols-1280)-1)*var(--kit-gap,1rem))/var(--kit-cols-1280))]',
163
+ track: clamp(measured.gap, 0, 200) !== undefined ? 'gap-[var(--kit-gap)]' : '',
164
+ }
165
+ }
@@ -0,0 +1,67 @@
1
+ // Every address a kit component prints (href, action, src, formaction, poster) passes through here. A site's content
2
+ // is text a person or a migration wrote, so an address can be `javascript:…` or `data:text/html,…`; Astro escapes
3
+ // quotes but never looks at the scheme. Allowed: http, https, mailto, tel, and anything with no scheme (`/path`,
4
+ // `./x`, `../x`, `?q=1`, `#top`, `//host/x`). Anything else is refused: `safeUrl` gives `undefined` (drop the
5
+ // attribute), `safeHref` gives `#` (the link stays, harmless).
6
+ //
7
+ // The check reads an address the way a browser does before it looks for a scheme: character references decoded
8
+ // (`&#106;avascript:`, `javascript&colon;`), leading and trailing control characters and spaces removed, and every
9
+ // tab and line break removed wherever it sits (`java<TAB>script:`).
10
+
11
+ const SAFE_SCHEMES = new Set(['http', 'https', 'mailto', 'tel'])
12
+ /** A raster image written inline; SVG and HTML are not allowed (they can carry script). */
13
+ const INLINE_IMAGE = /^data:image\/(?:png|jpe?g|gif|webp|avif)[;,]/
14
+
15
+ const NAMED: Record<string, string> = { colon: ':', tab: '\t', newline: '\n', amp: '&', lpar: '(', rpar: ')', sol: '/', num: '#', quot: '"', apos: '\'', lt: '<', gt: '>', nbsp: ' ' }
16
+
17
+ function decodeOnce(text: string): string {
18
+ return text.replace(/&(?:#(\d{1,7})|#[xX]([0-9a-fA-F]{1,6})|([a-zA-Z]{2,8}));?/g, (whole, dec: string | undefined, hex: string | undefined, name: string | undefined) => {
19
+ const code = dec ? Number(dec) : hex ? Number.parseInt(hex, 16) : undefined
20
+ if (code !== undefined) return code > 0 && code <= 0x10FFFF ? String.fromCodePoint(code) : ''
21
+ return name !== undefined && name.toLowerCase() in NAMED ? NAMED[name.toLowerCase()]! : whole
22
+ })
23
+ }
24
+
25
+ /** The address as a browser would see it before reading its scheme. */
26
+ function probe(value: string): string {
27
+ let text = value
28
+ // References can be nested (`&amp;#106;`): decode until nothing changes, a few rounds at most.
29
+ for (let round = 0; round < 4; round++) {
30
+ const next = decodeOnce(text)
31
+ if (next === text) break
32
+ text = next
33
+ }
34
+ // oxlint-disable-next-line no-control-regex
35
+ return text.replace(/^[\u0000- ]+|[\u0000- ]+$/g, '').replace(/[\t\n\r]/g, '')
36
+ }
37
+
38
+ function schemeOf(value: string): string | undefined {
39
+ return /^([a-z][a-z0-9+.-]*):/i.exec(probe(value))?.[1]?.toLowerCase()
40
+ }
41
+
42
+ /** In `astro dev` (not in a build or a test) a refused address says so, with the scheme it was refused for. */
43
+ function refused(scheme: string): undefined {
44
+ const mode = (import.meta as { env?: { DEV?: boolean, MODE?: string } }).env
45
+ if (mode?.DEV && mode.MODE !== 'test') console.warn(`[astro-kit] refused an address with the "${scheme}:" scheme; allowed: http, https, mailto, tel and relative addresses.`)
46
+ return undefined
47
+ }
48
+
49
+ /** The address if it is safe to print as a link or form target; otherwise `undefined` (leave the attribute off). */
50
+ export function safeUrl(value: string | null | undefined): string | undefined {
51
+ if (value === null || value === undefined) return undefined
52
+ const text = String(value)
53
+ const scheme = schemeOf(text)
54
+ return scheme === undefined || SAFE_SCHEMES.has(scheme) ? text.trim() : refused(scheme)
55
+ }
56
+
57
+ /** The address if it is safe; otherwise `#`, so a link keeps its place and goes nowhere. */
58
+ export function safeHref(value: string | null | undefined): string {
59
+ return safeUrl(value) ?? '#'
60
+ }
61
+
62
+ /** As `safeUrl`, for an image or media source: also an inline raster image (`data:image/png;base64,…`). */
63
+ export function safeSrc(value: string | null | undefined): string | undefined {
64
+ if (value === null || value === undefined) return undefined
65
+ const text = String(value)
66
+ return INLINE_IMAGE.test(probe(text).toLowerCase()) ? text.trim() : safeUrl(text)
67
+ }
@@ -0,0 +1,73 @@
1
+ // The network a profile address belongs to, read from its host (`linkedin.com` → LinkedIn), with its mark.
2
+ // Shared by SocialLinks and Team; one table, so a new network is added once.
3
+ // Marks: Simple Icons (CC0-1.0, https://simpleicons.org), 24×24, drawn in the text colour.
4
+
5
+ export interface Network {
6
+ key: string
7
+ label: string
8
+ }
9
+
10
+ const NETWORKS: Record<string, Network> = {
11
+ 'linkedin.com': { key: 'linkedin', label: 'LinkedIn' },
12
+ 'github.com': { key: 'github', label: 'GitHub' },
13
+ 'x.com': { key: 'x', label: 'X' },
14
+ 'twitter.com': { key: 'x', label: 'X' },
15
+ 'instagram.com': { key: 'instagram', label: 'Instagram' },
16
+ 'facebook.com': { key: 'facebook', label: 'Facebook' },
17
+ 'fb.com': { key: 'facebook', label: 'Facebook' },
18
+ 'youtube.com': { key: 'youtube', label: 'YouTube' },
19
+ 'youtu.be': { key: 'youtube', label: 'YouTube' },
20
+ 'dribbble.com': { key: 'dribbble', label: 'Dribbble' },
21
+ 'behance.net': { key: 'behance', label: 'Behance' },
22
+ 'medium.com': { key: 'medium', label: 'Medium' },
23
+ 'pinterest.com': { key: 'pinterest', label: 'Pinterest' },
24
+ 'pin.it': { key: 'pinterest', label: 'Pinterest' },
25
+ 'tiktok.com': { key: 'tiktok', label: 'TikTok' },
26
+ 'whatsapp.com': { key: 'whatsapp', label: 'WhatsApp' },
27
+ 'wa.me': { key: 'whatsapp', label: 'WhatsApp' },
28
+ 'telegram.org': { key: 'telegram', label: 'Telegram' },
29
+ 't.me': { key: 'telegram', label: 'Telegram' },
30
+ 'discord.com': { key: 'discord', label: 'Discord' },
31
+ 'discord.gg': { key: 'discord', label: 'Discord' },
32
+ 'vimeo.com': { key: 'vimeo', label: 'Vimeo' },
33
+ 'spotify.com': { key: 'spotify', label: 'Spotify' },
34
+ 'snapchat.com': { key: 'snapchat', label: 'Snapchat' },
35
+ 'reddit.com': { key: 'reddit', label: 'Reddit' },
36
+ }
37
+
38
+ export const NETWORK_MARKS: Record<string, string> = {
39
+ linkedin: 'M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037c-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85c3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433a2.06 2.06 0 0 1-2.063-2.065a2.064 2.064 0 1 1 2.063 2.065m1.782 13.019H3.555V9h3.564zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0z',
40
+ github: 'M12 .297c-6.63 0-12 5.373-12 12c0 5.303 3.438 9.8 8.205 11.385c.6.113.82-.258.82-.577c0-.285-.01-1.04-.015-2.04c-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729c1.205.084 1.838 1.236 1.838 1.236c1.07 1.835 2.809 1.305 3.495.998c.108-.776.417-1.305.76-1.605c-2.665-.3-5.466-1.332-5.466-5.93c0-1.31.465-2.38 1.235-3.22c-.135-.303-.54-1.523.105-3.176c0 0 1.005-.322 3.3 1.23c.96-.267 1.98-.399 3-.405c1.02.006 2.04.138 3 .405c2.28-1.552 3.285-1.23 3.285-1.23c.645 1.653.24 2.873.12 3.176c.765.84 1.23 1.91 1.23 3.22c0 4.61-2.805 5.625-5.475 5.92c.42.36.81 1.096.81 2.22c0 1.606-.015 2.896-.015 3.286c0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12',
41
+ x: 'M14.234 10.162L22.977 0h-2.072l-7.591 8.824L7.251 0H.258l9.168 13.343L.258 24H2.33l8.016-9.318L16.749 24h6.993zm-2.837 3.299l-.929-1.329L3.076 1.56h3.182l5.965 8.532l.929 1.329l7.754 11.09h-3.182z',
42
+ instagram: 'M7.03.084c-1.277.06-2.149.264-2.91.563a5.9 5.9 0 0 0-2.124 1.388a5.9 5.9 0 0 0-1.38 2.127C.321 4.926.12 5.8.064 7.076s-.069 1.688-.063 4.947s.021 3.667.083 4.947c.061 1.277.264 2.149.563 2.911c.308.789.72 1.457 1.388 2.123a5.9 5.9 0 0 0 2.129 1.38c.763.295 1.636.496 2.913.552c1.278.056 1.689.069 4.947.063s3.668-.021 4.947-.082c1.28-.06 2.147-.265 2.91-.563a5.9 5.9 0 0 0 2.123-1.388a5.9 5.9 0 0 0 1.38-2.129c.295-.763.496-1.636.551-2.912c.056-1.28.07-1.69.063-4.948c-.006-3.258-.02-3.667-.081-4.947c-.06-1.28-.264-2.148-.564-2.911a5.9 5.9 0 0 0-1.387-2.123a5.9 5.9 0 0 0-2.128-1.38c-.764-.294-1.636-.496-2.914-.55C15.647.009 15.236-.006 11.977 0S8.31.021 7.03.084m.14 21.693c-1.17-.05-1.805-.245-2.228-.408a3.7 3.7 0 0 1-1.382-.895a3.7 3.7 0 0 1-.9-1.378c-.165-.423-.363-1.058-.417-2.228c-.06-1.264-.072-1.644-.08-4.848c-.006-3.204.006-3.583.061-4.848c.05-1.169.246-1.805.408-2.228c.216-.561.477-.96.895-1.382a3.7 3.7 0 0 1 1.379-.9c.423-.165 1.057-.361 2.227-.417c1.265-.06 1.644-.072 4.848-.08c3.203-.006 3.583.006 4.85.062c1.168.05 1.804.244 2.227.408c.56.216.96.475 1.382.895s.681.817.9 1.378c.165.422.362 1.056.417 2.227c.06 1.265.074 1.645.08 4.848c.005 3.203-.006 3.583-.061 4.848c-.051 1.17-.245 1.805-.408 2.23c-.216.56-.477.96-.896 1.38a3.7 3.7 0 0 1-1.378.9c-.422.165-1.058.362-2.226.418c-1.266.06-1.645.072-4.85.079s-3.582-.006-4.848-.06m9.783-16.192a1.44 1.44 0 1 0 1.437-1.442a1.44 1.44 0 0 0-1.437 1.442M5.839 12.012a6.161 6.161 0 1 0 12.323-.024a6.162 6.162 0 0 0-12.323.024M8 12.008A4 4 0 1 1 12.008 16A4 4 0 0 1 8 12.008',
43
+ facebook: 'M9.101 23.691v-7.98H6.627v-3.667h2.474v-1.58c0-4.085 1.848-5.978 5.858-5.978c.401 0 .955.042 1.468.103a9 9 0 0 1 1.141.195v3.325a9 9 0 0 0-.653-.036a27 27 0 0 0-.733-.009c-.707 0-1.259.096-1.675.309a1.7 1.7 0 0 0-.679.622c-.258.42-.374.995-.374 1.752v1.297h3.919l-.386 2.103l-.287 1.564h-3.246v8.245C19.396 23.238 24 18.179 24 12.044c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.628 3.874 10.35 9.101 11.647',
44
+ youtube: 'M23.498 6.186a3.02 3.02 0 0 0-2.122-2.136C19.505 3.545 12 3.545 12 3.545s-7.505 0-9.377.505A3.02 3.02 0 0 0 .502 6.186C0 8.07 0 12 0 12s0 3.93.502 5.814a3.02 3.02 0 0 0 2.122 2.136c1.871.505 9.376.505 9.376.505s7.505 0 9.377-.505a3.02 3.02 0 0 0 2.122-2.136C24 15.93 24 12 24 12s0-3.93-.502-5.814M9.545 15.568V8.432L15.818 12z',
45
+ dribbble: 'M12 24C5.385 24 0 18.615 0 12S5.385 0 12 0s12 5.385 12 12s-5.385 12-12 12m10.12-10.358c-.35-.11-3.17-.953-6.384-.438c1.34 3.684 1.887 6.684 1.992 7.308a10.28 10.28 0 0 0 4.395-6.87zm-6.115 7.808c-.153-.9-.75-4.032-2.19-7.77l-.066.02c-5.79 2.015-7.86 6.025-8.04 6.4a10.16 10.16 0 0 0 6.29 2.166c1.42 0 2.77-.29 4-.814zm-11.62-2.58c.232-.4 3.045-5.055 8.332-6.765q.202-.067.405-.12q-.392-.879-.832-1.74C7.17 11.775 2.206 11.71 1.756 11.7l-.004.312c0 2.633.998 5.037 2.634 6.855zm-2.42-8.955c.46.008 4.683.026 9.477-1.248a66 66 0 0 0-3.8-5.928a10.28 10.28 0 0 0-5.676 7.17zM9.6 2.052c.282.38 2.145 2.914 3.822 6c3.645-1.365 5.19-3.44 5.373-3.702A10.2 10.2 0 0 0 12 1.764c-.825 0-1.63.1-2.4.285zm10.335 3.483c-.218.29-1.935 2.493-5.724 4.04c.24.49.47.985.68 1.486c.08.18.15.36.22.53c3.41-.43 6.8.26 7.14.33c-.02-2.42-.88-4.64-2.31-6.38z',
46
+ behance: 'M16.969 16.927a2.56 2.56 0 0 0 1.901.677a2.5 2.5 0 0 0 1.531-.475c.362-.235.636-.584.779-.99h2.585a5.1 5.1 0 0 1-1.9 2.896a5.3 5.3 0 0 1-3.091.88a5.8 5.8 0 0 1-2.284-.433a4.9 4.9 0 0 1-1.723-1.211a5.7 5.7 0 0 1-1.08-1.874a7 7 0 0 1-.383-2.393c-.005-.8.129-1.595.396-2.349a5.31 5.31 0 0 1 5.088-3.604a4.9 4.9 0 0 1 2.376.563c.661.362 1.231.87 1.668 1.485a6.2 6.2 0 0 1 .943 2.133c.194.821.263 1.666.205 2.508h-7.699c-.063.79.184 1.574.688 2.187M6.947 4.084a8 8 0 0 1 1.928.198a4.3 4.3 0 0 1 1.49.638c.418.303.748.711.958 1.182c.241.579.357 1.203.341 1.83a3.5 3.5 0 0 1-.506 1.961a3.7 3.7 0 0 1-1.503 1.287a3.6 3.6 0 0 1 2.027 1.437c.464.747.697 1.615.67 2.494a4.6 4.6 0 0 1-.423 2.032a3.95 3.95 0 0 1-1.163 1.413a5.1 5.1 0 0 1-1.683.807a7 7 0 0 1-1.928.259H0V4.084zm-.235 12.9q.464.006.916-.099a2.2 2.2 0 0 0 .766-.332c.228-.158.411-.371.534-.619c.142-.317.208-.663.191-1.009a2.08 2.08 0 0 0-.642-1.715a2.62 2.62 0 0 0-1.696-.505h-3.54v4.279zm13.635-5.967a2.13 2.13 0 0 0-1.654-.619a2.34 2.34 0 0 0-1.163.259a2.5 2.5 0 0 0-.738.62a2.4 2.4 0 0 0-.396.792q-.111.36-.137.734h4.769a3.24 3.24 0 0 0-.679-1.785zm-13.813-.648a2.25 2.25 0 0 0 1.423-.433c.399-.355.607-.88.56-1.413a1.9 1.9 0 0 0-.178-.891a1.3 1.3 0 0 0-.495-.533a1.85 1.85 0 0 0-.711-.274a4 4 0 0 0-.835-.073H3.241v3.631h3.293zM21.62 5.122h-5.976v1.527h5.976z',
47
+ medium: 'M4.21 0A4.2 4.2 0 0 0 0 4.21v15.58A4.2 4.2 0 0 0 4.21 24h15.58A4.2 4.2 0 0 0 24 19.79v-1.093a5 5 0 0 1-.422.02c-2.577 0-4.027-2.146-4.09-4.832a8 8 0 0 1 .022-.708c.093-1.186.475-2.241 1.105-3.022a3.9 3.9 0 0 1 1.395-1.1c.468-.237 1.127-.367 1.664-.367h.023q.151 0 .303.01V4.211A4.2 4.2 0 0 0 19.79 0Zm.198 5.583h4.165l3.588 8.435l3.59-8.435h3.864v.146l-.019.004c-.705.16-1.063.397-1.063 1.254h-.003l.003 10.274c.06.676.424.885 1.063 1.03l.02.004v.145h-4.923v-.145l.019-.005c.639-.144.994-.353 1.054-1.03V7.267l-4.745 11.15h-.261L6.15 7.569v9.445c0 .857.358 1.094 1.063 1.253l.02.004v.147H4.405v-.147l.019-.004c.705-.16 1.065-.397 1.065-1.253V6.987c0-.857-.358-1.094-1.064-1.254l-.018-.004zm19.25 3.668c-1.086.023-1.733 1.323-1.813 3.124H24V9.298a1.4 1.4 0 0 0-.342-.047m-1.862 3.632c-.1 1.756.86 3.239 2.204 3.634v-3.634z',
48
+ pinterest: 'M12.017 0C5.396 0 .029 5.367.029 11.987c0 5.079 3.158 9.417 7.618 11.162c-.105-.949-.199-2.403.041-3.439c.219-.937 1.406-5.957 1.406-5.957s-.359-.72-.359-1.781c0-1.663.967-2.911 2.168-2.911c1.024 0 1.518.769 1.518 1.688c0 1.029-.653 2.567-.992 3.992c-.285 1.193.6 2.165 1.775 2.165c2.128 0 3.768-2.245 3.768-5.487c0-2.861-2.063-4.869-5.008-4.869c-3.41 0-5.409 2.562-5.409 5.199c0 1.033.394 2.143.889 2.741c.099.12.112.225.085.345c-.09.375-.293 1.199-.334 1.363c-.053.225-.172.271-.401.165c-1.495-.69-2.433-2.878-2.433-4.646c0-3.776 2.748-7.252 7.92-7.252c4.158 0 7.392 2.967 7.392 6.923c0 4.135-2.607 7.462-6.233 7.462c-1.214 0-2.354-.629-2.758-1.379l-.749 2.848c-.269 1.045-1.004 2.352-1.498 3.146c1.123.345 2.306.535 3.55.535c6.607 0 11.985-5.365 11.985-11.987C23.97 5.39 18.592.026 11.985.026z',
49
+ tiktok: 'M12.525.02c1.31-.02 2.61-.01 3.91-.02c.08 1.53.63 3.09 1.75 4.17c1.12 1.11 2.7 1.62 4.24 1.79v4.03c-1.44-.05-2.89-.35-4.2-.97c-.57-.26-1.1-.59-1.62-.93c-.01 2.92.01 5.84-.02 8.75c-.08 1.4-.54 2.79-1.35 3.94c-1.31 1.92-3.58 3.17-5.91 3.21c-1.43.08-2.86-.31-4.08-1.03c-2.02-1.19-3.44-3.37-3.65-5.71c-.02-.5-.03-1-.01-1.49c.18-1.9 1.12-3.72 2.58-4.96c1.66-1.44 3.98-2.13 6.15-1.72c.02 1.48-.04 2.96-.04 4.44c-.99-.32-2.15-.23-3.02.37c-.63.41-1.11 1.04-1.36 1.75c-.21.51-.15 1.07-.14 1.61c.24 1.64 1.82 3.02 3.5 2.87c1.12-.01 2.19-.66 2.77-1.61c.19-.33.4-.67.41-1.06c.1-1.79.06-3.57.07-5.36c.01-4.03-.01-8.05.02-12.07',
50
+ whatsapp: 'M17.472 14.382c-.297-.149-1.758-.867-2.03-.967c-.273-.099-.471-.148-.67.15c-.197.297-.767.966-.94 1.164c-.173.199-.347.223-.644.075c-.297-.15-1.255-.463-2.39-1.475c-.883-.788-1.48-1.761-1.653-2.059c-.173-.297-.018-.458.13-.606c.134-.133.298-.347.446-.52s.198-.298.298-.497c.099-.198.05-.371-.025-.52s-.669-1.612-.916-2.207c-.242-.579-.487-.5-.669-.51a13 13 0 0 0-.57-.01c-.198 0-.52.074-.792.372c-.272.297-1.04 1.016-1.04 2.479c0 1.462 1.065 2.875 1.213 3.074s2.096 3.2 5.077 4.487c.709.306 1.262.489 1.694.625c.712.227 1.36.195 1.871.118c.571-.085 1.758-.719 2.006-1.413s.248-1.289.173-1.413c-.074-.124-.272-.198-.57-.347m-5.421 7.403h-.004a9.87 9.87 0 0 1-5.031-1.378l-.361-.214l-3.741.982l.998-3.648l-.235-.374a9.86 9.86 0 0 1-1.51-5.26c.001-5.45 4.436-9.884 9.888-9.884c2.64 0 5.122 1.03 6.988 2.898a9.82 9.82 0 0 1 2.893 6.994c-.003 5.45-4.437 9.884-9.885 9.884m8.413-18.297A11.82 11.82 0 0 0 12.05 0C5.495 0 .16 5.335.157 11.892c0 2.096.547 4.142 1.588 5.945L.057 24l6.305-1.654a11.9 11.9 0 0 0 5.683 1.448h.005c6.554 0 11.89-5.335 11.893-11.893a11.82 11.82 0 0 0-3.48-8.413',
51
+ telegram: 'M11.944 0A12 12 0 0 0 0 12a12 12 0 0 0 12 12a12 12 0 0 0 12-12A12 12 0 0 0 12 0zm4.962 7.224c.1-.002.321.023.465.14a.5.5 0 0 1 .171.325c.016.093.036.306.02.472c-.18 1.898-.962 6.502-1.36 8.627c-.168.9-.499 1.201-.82 1.23c-.696.065-1.225-.46-1.9-.902c-1.056-.693-1.653-1.124-2.678-1.8c-1.185-.78-.417-1.21.258-1.91c.177-.184 3.247-2.977 3.307-3.23c.007-.032.014-.15-.056-.212s-.174-.041-.249-.024q-.159.037-5.061 3.345q-.72.495-1.302.48c-.428-.008-1.252-.241-1.865-.44c-.752-.245-1.349-.374-1.297-.789q.04-.324.893-.663q5.247-2.286 6.998-3.014c3.332-1.386 4.025-1.627 4.476-1.635',
52
+ discord: 'M20.317 4.37a19.8 19.8 0 0 0-4.885-1.515a.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.3 18.3 0 0 0-5.487 0a13 13 0 0 0-.617-1.25a.08.08 0 0 0-.079-.037A19.7 19.7 0 0 0 3.677 4.37a.1.1 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.08.08 0 0 0 .031.057a19.9 19.9 0 0 0 5.993 3.03a.08.08 0 0 0 .084-.028a14 14 0 0 0 1.226-1.994a.076.076 0 0 0-.041-.106a13 13 0 0 1-1.872-.892a.077.077 0 0 1-.008-.128a10 10 0 0 0 .372-.292a.07.07 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.07.07 0 0 1 .078.01q.181.149.373.292a.077.077 0 0 1-.006.127a12.3 12.3 0 0 1-1.873.892a.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.08.08 0 0 0 .084.028a19.8 19.8 0 0 0 6.002-3.03a.08.08 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.06.06 0 0 0-.031-.03M8.02 15.33c-1.182 0-2.157-1.085-2.157-2.419c0-1.333.956-2.419 2.157-2.419c1.21 0 2.176 1.096 2.157 2.42c0 1.333-.956 2.418-2.157 2.418m7.975 0c-1.183 0-2.157-1.085-2.157-2.419c0-1.333.955-2.419 2.157-2.419c1.21 0 2.176 1.096 2.157 2.42c0 1.333-.946 2.418-2.157 2.418',
53
+ vimeo: 'M23.977 6.417c-.105 2.338-1.74 5.543-4.894 9.609c-3.268 4.247-6.026 6.37-8.29 6.37c-1.41 0-2.578-1.294-3.553-3.881L5.322 11.4Q4.244 7.524 3.01 7.523q-.27 0-1.881 1.132l-1.13-1.457A315 315 0 0 0 3.502 4.07C5.08 2.702 6.266 1.985 7.055 1.91q2.8-.27 3.447 3.839q.698 4.43.97 5.507q.81 3.675 1.777 3.674q.752 0 2.265-2.385q1.505-2.383 1.612-3.628c.144-1.371-.395-2.061-1.614-2.061c-.574 0-1.167.12-1.777.39C14.92 3.38 17.169 1.49 20.497 1.61c2.473.06 3.628 1.664 3.493 4.797z',
54
+ spotify: 'M12 0C5.4 0 0 5.4 0 12s5.4 12 12 12s12-5.4 12-12S18.66 0 12 0m5.521 17.34c-.24.359-.66.48-1.021.24c-2.82-1.74-6.36-2.101-10.561-1.141c-.418.122-.779-.179-.899-.539c-.12-.421.18-.78.54-.9c4.56-1.021 8.52-.6 11.64 1.32c.42.18.479.659.301 1.02m1.44-3.3c-.301.42-.841.6-1.262.3c-3.239-1.98-8.159-2.58-11.939-1.38c-.479.12-1.02-.12-1.14-.6s.12-1.021.6-1.141C9.6 9.9 15 10.561 18.72 12.84c.361.181.54.78.241 1.2m.12-3.36C15.24 8.4 8.82 8.16 5.16 9.301c-.6.179-1.2-.181-1.38-.721c-.18-.601.18-1.2.72-1.381c4.26-1.26 11.28-1.02 15.721 1.621c.539.3.719 1.02.419 1.56c-.299.421-1.02.599-1.559.3',
55
+ snapchat: 'M12.206.793c.99 0 4.347.276 5.93 3.821c.529 1.193.403 3.219.299 4.847l-.003.06c-.012.18-.022.345-.03.51c.075.045.203.09.401.09c.3-.016.659-.12 1.033-.301a1 1 0 0 1 .464-.104c.182 0 .359.029.509.09c.45.149.734.479.734.838q.022.674-1.213 1.168c-.089.029-.209.075-.344.119c-.45.135-1.139.36-1.333.81c-.09.224-.061.524.12.868l.015.015c.06.136 1.526 3.475 4.791 4.014c.255.044.435.27.42.509a.6.6 0 0 1-.045.225c-.24.569-1.273.988-3.146 1.271c-.059.091-.12.375-.164.57c-.029.179-.074.36-.134.553c-.076.271-.27.405-.555.405h-.03a3 3 0 0 1-.538-.074a6 6 0 0 0-1.273-.135c-.3 0-.599.015-.913.074c-.6.104-1.123.464-1.723.884c-.853.599-1.826 1.288-3.294 1.288c-.06 0-.119-.015-.18-.015h-.149c-1.468 0-2.427-.675-3.279-1.288c-.599-.42-1.107-.779-1.707-.884a7 7 0 0 0-.928-.074c-.54 0-.958.089-1.272.149a3 3 0 0 1-.54.074c-.374 0-.523-.224-.583-.42c-.061-.192-.09-.389-.135-.567c-.046-.181-.105-.494-.166-.57c-1.918-.222-2.95-.642-3.189-1.226a.6.6 0 0 1-.055-.225a.496.496 0 0 1 .42-.509c3.264-.54 4.73-3.879 4.791-4.02l.016-.029c.18-.345.224-.645.119-.869c-.195-.434-.884-.658-1.332-.809a2 2 0 0 1-.346-.119c-1.107-.435-1.257-.93-1.197-1.273c.09-.479.674-.793 1.168-.793c.146 0 .27.029.383.074c.42.194.789.3 1.104.3c.234 0 .384-.06.465-.105l-.046-.569c-.098-1.626-.225-3.651.307-4.837C7.392 1.077 10.739.807 11.727.807l.419-.015h.06z',
56
+ reddit: 'M12 0C5.373 0 0 5.373 0 12c0 3.314 1.343 6.314 3.515 8.485l-2.286 2.286A.72.72 0 0 0 1.738 24H12c6.627 0 12-5.373 12-12S18.627 0 12 0m4.388 3.199a1.999 1.999 0 1 1-1.947 2.46v.002a2.37 2.37 0 0 0-2.032 2.341v.007c1.776.067 3.4.567 4.686 1.363a2.802 2.802 0 1 1 2.908 4.753c-.088 3.256-3.637 5.876-7.997 5.876c-4.361 0-7.905-2.617-7.998-5.87a2.8 2.8 0 0 1 1.189-5.34c.645 0 1.239.218 1.712.585c1.275-.79 2.881-1.291 4.64-1.365v-.01a3.23 3.23 0 0 1 2.88-3.207a2 2 0 0 1 1.959-1.595m-8.085 8.376c-.784 0-1.459.78-1.506 1.797s.64 1.429 1.426 1.429s1.371-.369 1.418-1.385s-.553-1.841-1.338-1.841m7.406 0c-.786 0-1.385.824-1.338 1.841s.634 1.385 1.418 1.385c.785 0 1.473-.413 1.426-1.429c-.046-1.017-.721-1.797-1.506-1.797m-3.703 4.013c-.974 0-1.907.048-2.77.135a.222.222 0 0 0-.183.305a3.2 3.2 0 0 0 2.953 1.964a3.2 3.2 0 0 0 2.953-1.964a.222.222 0 0 0-.184-.305a28 28 0 0 0-2.769-.135',
57
+ }
58
+
59
+ /** A profile address's host without `www.`; empty for `mailto:`, `tel:` and relative addresses. */
60
+ export const hostOf = (href: string): string => {
61
+ try { return new URL(href).hostname.replace(/^www\./, '') } catch { return '' }
62
+ }
63
+
64
+ /** The network of an address, by its host or a parent of it (`m.facebook.com`); undefined for an unknown one. */
65
+ export const networkOf = (href: string): Network | undefined => {
66
+ const host = hostOf(href)
67
+ if (!host) return undefined
68
+ const known = Object.keys(NETWORKS).find(name => host === name || host.endsWith(`.${name}`))
69
+ return known ? NETWORKS[known] : undefined
70
+ }
71
+
72
+ /** The text an address is shown as when no network is known: its host, else the address itself. */
73
+ export const addressLabel = (href: string): string => hostOf(href) || href
@@ -9,6 +9,7 @@
9
9
  // <details> is the only built-in element that starts visible and hides itself.
10
10
  // Dismissal is not remembered; the bar comes back on the next page.
11
11
  import { tv } from 'tailwind-variants'
12
+ import { safeHref } from '../_shared/safe-url'
12
13
 
13
14
  const announcement = tv({
14
15
  slots: {
@@ -48,6 +49,6 @@ const s = announcement({ tone })
48
49
  <summary class={s.close()} aria-label={closeLabel}><span aria-hidden="true">✕</span></summary>
49
50
  <div class={s.bar()}>
50
51
  <p>{message}</p>
51
- {href && <a href={href} class={s.link()}>{linkLabel ?? 'Learn more'}</a>}
52
+ {href && <a href={safeHref(href)} class={s.link()}>{linkLabel ?? 'Learn more'}</a>}
52
53
  </div>
53
54
  </details>
@@ -4,7 +4,7 @@
4
4
  "description": "A one-line notice above the header (sale, event, changed hours) with an optional link. The visitor closes it with no JS (an open <details> whose summary is the close button); the dismissal is not remembered across pages.",
5
5
  "category": "chrome",
6
6
  "uses": [],
7
- "shared": [],
7
+ "shared": ["safe-url.ts"],
8
8
  "variants": {
9
9
  "tone": {
10
10
  "options": [
@@ -8,6 +8,7 @@ import KitImage from '../_shared/KitImage.astro'
8
8
  import Section, { type Tone } from '../_shared/Section.astro'
9
9
  import { measuredGrid } from '../_shared/measured'
10
10
  import type { ActionInput, ImageInput, MeasuredStyle } from '../_shared/types'
11
+ import { safeHref } from '../_shared/safe-url'
11
12
 
12
13
  const band = tv({
13
14
  slots: {
@@ -116,14 +117,14 @@ const hasCopy = Boolean(heading || eyebrow || lead || actions.length)
116
117
  {item.title && (
117
118
  <ItemHeading class={s.itemTitle()}>
118
119
  {item.href && !item.linkLabel
119
- ? <a href={item.href} class="text-current no-underline after:absolute after:inset-0 after:content-[''] hover:text-accent-text">{item.title}</a>
120
+ ? <a href={safeHref(item.href)} class="text-current no-underline after:absolute after:inset-0 after:content-[''] hover:text-accent-text">{item.title}</a>
120
121
  : item.title}
121
122
  </ItemHeading>
122
123
  )}
123
124
  {item.meta && <p class={s.itemMeta()}>{item.meta}</p>}
124
125
  {item.text && <p class={s.itemText()} data-kit-text>{item.text}</p>}
125
126
  {item.points && item.points.length > 0 && <ul class={s.itemPoints()}>{item.points.map(point => <li>{point}</li>)}</ul>}
126
- {item.href && item.linkLabel && <a href={item.href} class="font-medium text-accent-text underline-offset-4 hover:underline">{item.linkLabel}</a>}
127
+ {item.href && item.linkLabel && <a href={safeHref(item.href)} class="font-medium text-accent-text underline-offset-4 hover:underline">{item.linkLabel}</a>}
127
128
  </li>
128
129
  ))}
129
130
  </ul>
@@ -9,6 +9,7 @@
9
9
  "KitImage.astro",
10
10
  "Section.astro",
11
11
  "measured.ts",
12
+ "safe-url.ts",
12
13
  "types.ts"
13
14
  ],
14
15
  "variants": {
@@ -3,6 +3,7 @@
3
3
  // the matching BreadcrumbList JSON-LD.
4
4
  import { tv } from 'tailwind-variants'
5
5
  import type { LinkInput } from '../_shared/types'
6
+ import { safeHref } from '../_shared/safe-url'
6
7
 
7
8
  const breadcrumb = tv({
8
9
  slots: {
@@ -46,7 +47,7 @@ const jsonLd = schema
46
47
  <ol class={slots.list()}>
47
48
  {items.map(item => (
48
49
  <li class="flex items-center gap-2">
49
- <a href={item.href} class={slots.link()}>{item.label}</a>
50
+ <a href={safeHref(item.href)} class={slots.link()}>{item.label}</a>
50
51
  <span aria-hidden="true">{MARKS[separator]}</span>
51
52
  </li>
52
53
  ))}
@@ -4,7 +4,7 @@
4
4
  "description": "The page's ancestors as links followed by the page itself; optionally emits BreadcrumbList JSON-LD.",
5
5
  "category": "content",
6
6
  "uses": [],
7
- "shared": ["types.ts"],
7
+ "shared": ["safe-url.ts", "types.ts"],
8
8
  "variants": {
9
9
  "separator": { "options": ["slash", "chevron", "dot"], "default": "slash", "description": "Mark between items." }
10
10
  },
@@ -7,6 +7,7 @@ import Section, { type Tone } from '../_shared/Section.astro'
7
7
  import { measuredGrid } from '../_shared/measured'
8
8
  import type { MeasuredStyle } from '../_shared/types'
9
9
  import { imageOf, type ImageSource } from '../_shared/image'
10
+ import { safeHref } from '../_shared/safe-url'
10
11
 
11
12
  const grid = tv({
12
13
  slots: {
@@ -79,7 +80,7 @@ const headingId = title ? `cards-${title.toLowerCase().replace(/[^a-z0-9]+/g, '-
79
80
  {item.eyebrow && <p class="text-sm font-medium text-accent-text">{item.eyebrow}</p>}
80
81
  <CardHeading class={cardTitle()}>
81
82
  {item.href
82
- ? <a href={item.href} class="text-current no-underline after:absolute after:inset-0 after:content-[''] hover:text-accent-text">{item.title}</a>
83
+ ? <a href={safeHref(item.href)} class="text-current no-underline after:absolute after:inset-0 after:content-[''] hover:text-accent-text">{item.title}</a>
83
84
  : item.title}
84
85
  </CardHeading>
85
86
  {item.text && <p class={text({ class: dim })} data-kit-text>{item.text}</p>}
@@ -9,6 +9,7 @@
9
9
  "Section.astro",
10
10
  "image.ts",
11
11
  "measured.ts",
12
+ "safe-url.ts",
12
13
  "types.ts"
13
14
  ],
14
15
  "variants": {
@@ -7,6 +7,7 @@
7
7
  // button) reopens the bar, so consent is withdrawn as easily as it is given.
8
8
  // Replaces cookie plugins (Cookie Notice, Complianz, CookieYes, Cookiebot).
9
9
  import { tv } from 'tailwind-variants'
10
+ import { safeHref } from '../_shared/safe-url'
10
11
 
11
12
  const consent = tv({
12
13
  slots: {
@@ -49,7 +50,7 @@ const s = consent({ position })
49
50
  <div data-kit-consent role="region" aria-label={regionLabel} class={s.root()} hidden>
50
51
  <p class={s.message()}>
51
52
  {message}
52
- {policyHref && <> <a href={policyHref} class={s.link()}>{policyLabel}</a></>}
53
+ {policyHref && <> <a href={safeHref(policyHref)} class={s.link()}>{policyLabel}</a></>}
53
54
  </p>
54
55
  <div class={s.actions()}>
55
56
  <button type="button" data-kit-consent-choice="granted" class={s.accept()}>{acceptLabel}</button>
@@ -4,7 +4,7 @@
4
4
  "description": "Cookie consent bar with accept and decline. Consent is denied by default: until the visitor explicitly accepts, <html data-consent> is \"denied\" (set before first paint) and nothing may start measurement. The choice is stored in localStorage (`kit-consent`), set as <html data-consent=\"granted|denied\"> and dispatched as a `kit:consent` event on document with {granted}; the site-settings analytics gate reads data-consent and loads analytics only when it is \"granted\". The bar shows only while no choice is stored, with no flash on later visits.",
5
5
  "category": "chrome",
6
6
  "uses": [],
7
- "shared": [],
7
+ "shared": ["safe-url.ts"],
8
8
  "variants": {
9
9
  "position": {
10
10
  "options": [
@@ -7,6 +7,7 @@ import { tv } from 'tailwind-variants'
7
7
  import Section from '../_shared/Section.astro'
8
8
  import { measuredGrid } from '../_shared/measured'
9
9
  import type { MeasuredStyle } from '../_shared/types'
10
+ import { safeHref } from '../_shared/safe-url'
10
11
 
11
12
  const details = tv({
12
13
  slots: {
@@ -63,13 +64,13 @@ const shown = Boolean(email || phone || address || hours || mapUrl)
63
64
  {heading && <h2 id={headingId} class={s.heading()}>{heading}</h2>}
64
65
  <dl class={s.list({ class: measuredGrid(measured) })}>
65
66
  {email && <div><dt class={s.term()}>{emailLabel}</dt><dd class={s.value()}><a href={`mailto:${email}`} class={s.link()}>{email}</a></dd></div>}
66
- {phone && <div><dt class={s.term()}>{phoneLabel}</dt><dd class={s.value()}><a href={tel} class={s.link()}>{phone}</a></dd></div>}
67
+ {phone && <div><dt class={s.term()}>{phoneLabel}</dt><dd class={s.value()}><a href={safeHref(tel)} class={s.link()}>{phone}</a></dd></div>}
67
68
  {(address || mapUrl) && (
68
69
  <div>
69
70
  <dt class={s.term()}>{addressLabel}</dt>
70
71
  <dd class={s.value()}>
71
72
  {address && <address class="not-italic">{address}</address>}
72
- {mapUrl && <a href={mapUrl} class:list={[s.link(), address && 'mt-2 inline-block']}>{mapLabel}</a>}
73
+ {mapUrl && <a href={safeHref(mapUrl)} class:list={[s.link(), address && 'mt-2 inline-block']}>{mapLabel}</a>}
73
74
  </dd>
74
75
  </div>
75
76
  )}
@@ -7,6 +7,7 @@
7
7
  "shared": [
8
8
  "Section.astro",
9
9
  "measured.ts",
10
+ "safe-url.ts",
10
11
  "types.ts"
11
12
  ],
12
13
  "variants": {
@@ -16,6 +16,7 @@ import Embed from '../embed/Embed.astro'
16
16
  import StudioForm from '../../studio/StudioForm.astro'
17
17
  import { formHome } from '../../../lib/features'
18
18
  import { contactSectionBare } from './contact-form'
19
+ import { safeHref } from '../_shared/safe-url'
19
20
 
20
21
  export interface ContactDetail {
21
22
  /** What the detail is (Phone, Email); a source's icon list often has none. */
@@ -127,7 +128,7 @@ const FormHeading = heading ? 'h3' : 'h2'
127
128
  {details.map(detail => (
128
129
  <div class={detailsStyle === 'cards' ? s.card() : undefined}>
129
130
  <dt class={s.term()}>{detail.label}</dt>
130
- <dd class={s.value()}>{detail.href ? <a href={detail.href} class={LINK}>{detail.value}</a> : detail.value}</dd>
131
+ <dd class={s.value()}>{detail.href ? <a href={safeHref(detail.href)} class={LINK}>{detail.value}</a> : detail.value}</dd>
131
132
  </div>
132
133
  ))}
133
134
  </dl>
@@ -137,7 +138,7 @@ const FormHeading = heading ? 'h3' : 'h2'
137
138
  {details.map(detail => (
138
139
  <li class={s.value({ class: detailsStyle === 'cards' ? s.card() : undefined })}>
139
140
  {detail.label && <span class={s.term()}>{detail.label}: </span>}
140
- {detail.href ? <a href={detail.href} class={LINK}>{detail.value}</a> : detail.value}
141
+ {detail.href ? <a href={safeHref(detail.href)} class={LINK}>{detail.value}</a> : detail.value}
141
142
  </li>
142
143
  ))}
143
144
  </ul>
@@ -145,7 +146,7 @@ const FormHeading = heading ? 'h3' : 'h2'
145
146
  {hours && <p class={s.hours()}>{hours}</p>}
146
147
  {socials.length > 0 && (
147
148
  <ul class={s.socials()}>
148
- {socials.map(link => <li><a href={link.href} class={LINK}>{link.label}</a></li>)}
149
+ {socials.map(link => <li><a href={safeHref(link.href)} class={LINK}>{link.label}</a></li>)}
149
150
  </ul>
150
151
  )}
151
152
  {map && (
@@ -9,6 +9,7 @@
9
9
  "shared": [
10
10
  "Section.astro",
11
11
  "measured.ts",
12
+ "safe-url.ts",
12
13
  "types.ts"
13
14
  ],
14
15
  "variants": {
@@ -4,7 +4,7 @@
4
4
  "description": "A call to action: a short heading, an optional line of text and one or two buttons — across the whole section, or as a card inside it.",
5
5
  "category": "section",
6
6
  "uses": [],
7
- "shared": ["Button.astro", "KitImage.astro", "Section.astro", "measured.ts", "types.ts"],
7
+ "shared": ["Button.astro", "KitImage.astro", "Section.astro", "measured.ts", "safe-url.ts", "types.ts"],
8
8
  "variants": {
9
9
  "layout": { "options": ["inline", "centered", "stacked"], "default": "inline", "description": "inline: text left, buttons right on wide screens. centered: everything centred. stacked: heading, text and buttons in one column at the start edge on every width." },
10
10
  "frame": { "options": ["full", "card"], "default": "full", "description": "full: the tone fills the section. card: a rounded box in a plain section carries the tone." },