@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
|
@@ -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
|
+
]
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "embed",
|
|
3
|
+
"name": "Embed",
|
|
4
|
+
"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.",
|
|
5
|
+
"category": "content",
|
|
6
|
+
"uses": [],
|
|
7
|
+
"shared": ["KitImage.astro", "types.ts"],
|
|
8
|
+
"variants": {
|
|
9
|
+
"aspect": { "options": ["auto", "video", "portrait", "square", "map"], "default": "auto", "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." },
|
|
10
|
+
"width": { "options": ["prose", "page"], "default": "prose", "description": "prose: as wide as the reading column. page: the full page frame." }
|
|
11
|
+
},
|
|
12
|
+
"props": {
|
|
13
|
+
"url": { "type": "url", "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)." },
|
|
14
|
+
"address": { "type": "string", "description": "A place or address shown on a Google map when there is no url (Elementor google_maps address, Divi map address)." },
|
|
15
|
+
"title": { "type": "string", "description": "What it is: the player's accessible name and the poster's text." },
|
|
16
|
+
"caption": { "type": "string", "description": "A line under the embed." },
|
|
17
|
+
"poster": { "type": "object", "fields": { "src": { "type": "image", "required": true }, "alt": { "type": "string", "required": true }, "width": { "type": "integer" }, "height": { "type": "integer" } }, "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." },
|
|
18
|
+
"playLabel": { "type": "string", "description": "Poster action for a video or audio player (ui-strings embed.play)." },
|
|
19
|
+
"showLabel": { "type": "string", "description": "Poster action for a map or widget (ui-strings embed.show)." },
|
|
20
|
+
"id": { "type": "string", "description": "Element id." }
|
|
21
|
+
},
|
|
22
|
+
"slots": {},
|
|
23
|
+
"js": "vanilla",
|
|
24
|
+
"dependencies": {},
|
|
25
|
+
"sources": {
|
|
26
|
+
"gutenberg": ["core/embed"],
|
|
27
|
+
"elementor": ["elementor/video", "elementor/google_maps"],
|
|
28
|
+
"divi": ["divi/et_pb_video", "divi/et_pb_map"],
|
|
29
|
+
"classic": ["classic/youtube", "classic/vimeo", "classic/google-maps"]
|
|
30
|
+
},
|
|
31
|
+
"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."
|
|
32
|
+
}
|
package/components/faq/Faq.astro
CHANGED
|
@@ -14,10 +14,10 @@ export interface FaqItem {
|
|
|
14
14
|
|
|
15
15
|
const faq = tv({
|
|
16
16
|
slots: {
|
|
17
|
-
heading: 'mb-8 text-2xl font-
|
|
17
|
+
heading: 'mb-8 text-2xl [font-weight:var(--font-weight-heading,700)] tracking-tight text-balance md:text-3xl',
|
|
18
18
|
list: 'flex flex-col',
|
|
19
19
|
item: 'group',
|
|
20
|
-
summary: 'flex cursor-pointer list-none items-center justify-between gap-4 py-4 text-lg font-
|
|
20
|
+
summary: 'flex cursor-pointer list-none items-center justify-between gap-4 py-4 text-lg [font-weight:var(--font-weight-heading,600)] [&::-webkit-details-marker]:hidden focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent',
|
|
21
21
|
marker: 'size-5 shrink-0 transition-transform duration-200 group-open:rotate-45',
|
|
22
22
|
answer: 'prose-site prose max-w-none pb-5 text-current',
|
|
23
23
|
},
|
|
@@ -25,6 +25,8 @@ const faq = tv({
|
|
|
25
25
|
style: {
|
|
26
26
|
lines: { list: 'divide-y divide-line border-y border-line' },
|
|
27
27
|
cards: { list: 'gap-3', item: 'rounded-card border border-line px-5', summary: 'py-5' },
|
|
28
|
+
// WordPress's details block: the browser's own disclosure triangle, no rules or box.
|
|
29
|
+
plain: { list: 'gap-2', summary: 'list-item list-[revert] py-1 text-base [font-weight:inherit] [&::-webkit-details-marker]:inline', marker: 'hidden', answer: 'pt-2 pl-5' },
|
|
28
30
|
},
|
|
29
31
|
tone: { default: {}, muted: { item: 'bg-surface' }, inverse: { answer: 'prose-invert' }, accent: { answer: 'prose-invert' } },
|
|
30
32
|
},
|
|
@@ -37,7 +39,7 @@ const faq = tv({
|
|
|
37
39
|
interface Props {
|
|
38
40
|
items: FaqItem[]
|
|
39
41
|
heading?: string | undefined
|
|
40
|
-
style?: 'lines' | 'cards' | undefined
|
|
42
|
+
style?: 'lines' | 'cards' | 'plain' | undefined
|
|
41
43
|
/** One answer open at a time (`<details name>`). */
|
|
42
44
|
exclusive?: boolean | undefined
|
|
43
45
|
/** Open the first answer. */
|
|
@@ -32,5 +32,9 @@
|
|
|
32
32
|
{
|
|
33
33
|
"name": "cards-accent",
|
|
34
34
|
"props": { "style": "cards", "tone": "accent", "id": "faq-accent", "items": [{ "question": "How do I cancel my membership?", "answer": "<p>From your account page, at any time.</p>" }] }
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
"name": "plain",
|
|
38
|
+
"props": { "style": "plain", "id": "faq-plain", "items": [{ "question": "Do you accept guest essays?", "answer": "<p>Yes, send a short pitch first.</p>" }, { "question": "How often do you publish?", "answer": "<p>Two or three pieces a month.</p>" }] }
|
|
35
39
|
}
|
|
36
40
|
]
|
package/components/faq/meta.json
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
"uses": [],
|
|
7
7
|
"shared": ["Section.astro"],
|
|
8
8
|
"variants": {
|
|
9
|
-
"style": { "options": ["lines", "cards"], "default": "lines", "description": "lines: rows divided by rules. cards: each question in a rounded box." },
|
|
9
|
+
"style": { "options": ["lines", "cards", "plain"], "default": "lines", "description": "lines: rows divided by rules. cards: each question in a rounded box. plain: the browser's own disclosure triangle without rules or boxes (WordPress's details block)." },
|
|
10
10
|
"tone": { "options": ["default", "muted", "inverse", "accent"], "default": "default", "description": "Section background." }
|
|
11
11
|
},
|
|
12
12
|
"props": {
|
|
@@ -29,7 +29,7 @@ interface Props {
|
|
|
29
29
|
siteName: string
|
|
30
30
|
tagline?: string | undefined
|
|
31
31
|
/** Link groups for the `columns` layout. */
|
|
32
|
-
columns?: Array<{ title
|
|
32
|
+
columns?: Array<{ title?: string | undefined, label?: string | undefined, links: LinkInput[] }> | undefined
|
|
33
33
|
/** A single row of links (the footer menu). */
|
|
34
34
|
links?: LinkInput[] | undefined
|
|
35
35
|
social?: LinkInput[] | undefined
|
|
@@ -54,8 +54,8 @@ const line = copyright ?? `© ${new Date().getFullYear()} ${siteName}`
|
|
|
54
54
|
</div>
|
|
55
55
|
{layout === 'columns'
|
|
56
56
|
? columns.map(column => (
|
|
57
|
-
<nav aria-label={column.title}>
|
|
58
|
-
<p class="mb-3 font-semibold">{column.title}</p>
|
|
57
|
+
<nav aria-label={column.label ?? column.title} data-cr-part="nav-footer">
|
|
58
|
+
{column.title && <p class="mb-3 font-semibold">{column.title}</p>}
|
|
59
59
|
<ul class="flex flex-col gap-2">
|
|
60
60
|
{column.links.map(item => <li><a href={item.href} class={link()}>{item.label}</a></li>)}
|
|
61
61
|
</ul>
|
|
@@ -69,7 +69,7 @@ const line = copyright ?? `© ${new Date().getFullYear()} ${siteName}`
|
|
|
69
69
|
</ul>
|
|
70
70
|
)}
|
|
71
71
|
{links.length > 0 && (
|
|
72
|
-
<nav aria-label={navLabel}>
|
|
72
|
+
<nav aria-label={navLabel} data-cr-part="nav-footer">
|
|
73
73
|
<ul class="flex flex-wrap gap-x-6 gap-y-2">
|
|
74
74
|
{links.map(item => <li><a href={item.href} class={link()}>{item.label}</a></li>)}
|
|
75
75
|
</ul>
|
|
@@ -4,28 +4,121 @@
|
|
|
4
4
|
"description": "Site footer: brand and tagline, link columns or one row of links, social links and the copyright line.",
|
|
5
5
|
"category": "chrome",
|
|
6
6
|
"uses": [],
|
|
7
|
-
"shared": [
|
|
7
|
+
"shared": [
|
|
8
|
+
"types.ts"
|
|
9
|
+
],
|
|
8
10
|
"variants": {
|
|
9
|
-
"layout": {
|
|
10
|
-
|
|
11
|
+
"layout": {
|
|
12
|
+
"options": [
|
|
13
|
+
"simple",
|
|
14
|
+
"columns"
|
|
15
|
+
],
|
|
16
|
+
"default": "simple",
|
|
17
|
+
"description": "simple: brand left, links and copyright right. columns: brand column plus one column per link group."
|
|
18
|
+
},
|
|
19
|
+
"tone": {
|
|
20
|
+
"options": [
|
|
21
|
+
"default",
|
|
22
|
+
"muted",
|
|
23
|
+
"inverse"
|
|
24
|
+
],
|
|
25
|
+
"default": "default",
|
|
26
|
+
"description": "Surface with a top border, muted surface, or dark (ink) background."
|
|
27
|
+
}
|
|
11
28
|
},
|
|
12
29
|
"props": {
|
|
13
|
-
"siteName": {
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
"
|
|
19
|
-
|
|
30
|
+
"siteName": {
|
|
31
|
+
"type": "string",
|
|
32
|
+
"required": true,
|
|
33
|
+
"description": "Site title (site.title or site.organization)."
|
|
34
|
+
},
|
|
35
|
+
"tagline": {
|
|
36
|
+
"type": "string",
|
|
37
|
+
"description": "One line under the name (site.tagline)."
|
|
38
|
+
},
|
|
39
|
+
"columns": {
|
|
40
|
+
"type": "array",
|
|
41
|
+
"items": {
|
|
42
|
+
"type": "object",
|
|
43
|
+
"fields": {
|
|
44
|
+
"title": {
|
|
45
|
+
"type": "string",
|
|
46
|
+
"description": "Heading shown above the links; leave out when the source shows none."
|
|
47
|
+
},
|
|
48
|
+
"label": {
|
|
49
|
+
"type": "string",
|
|
50
|
+
"description": "Accessible name of the column navigation (the menu name); defaults to the title."
|
|
51
|
+
},
|
|
52
|
+
"links": {
|
|
53
|
+
"type": "array",
|
|
54
|
+
"required": true
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
},
|
|
58
|
+
"description": "Link groups for the columns layout; each footer menu or widget area becomes one."
|
|
59
|
+
},
|
|
60
|
+
"links": {
|
|
61
|
+
"type": "array",
|
|
62
|
+
"items": {
|
|
63
|
+
"type": "object",
|
|
64
|
+
"fields": {
|
|
65
|
+
"label": {
|
|
66
|
+
"type": "string",
|
|
67
|
+
"required": true
|
|
68
|
+
},
|
|
69
|
+
"href": {
|
|
70
|
+
"type": "url",
|
|
71
|
+
"required": true
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
},
|
|
75
|
+
"description": "One row of links (the footer menu) for the simple layout."
|
|
76
|
+
},
|
|
77
|
+
"social": {
|
|
78
|
+
"type": "array",
|
|
79
|
+
"items": {
|
|
80
|
+
"type": "object",
|
|
81
|
+
"fields": {
|
|
82
|
+
"label": {
|
|
83
|
+
"type": "string",
|
|
84
|
+
"required": true
|
|
85
|
+
},
|
|
86
|
+
"href": {
|
|
87
|
+
"type": "url",
|
|
88
|
+
"required": true
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
},
|
|
92
|
+
"description": "Social profile links (site.same_as)."
|
|
93
|
+
},
|
|
94
|
+
"copyright": {
|
|
95
|
+
"type": "string",
|
|
96
|
+
"description": "Copyright line; defaults to © <year> <siteName>."
|
|
97
|
+
},
|
|
98
|
+
"navLabel": {
|
|
99
|
+
"type": "string",
|
|
100
|
+
"description": "Accessible name of the footer navigation (ui-strings nav.footer)."
|
|
101
|
+
}
|
|
20
102
|
},
|
|
21
103
|
"slots": {},
|
|
22
104
|
"js": "none",
|
|
23
105
|
"dependencies": {},
|
|
24
106
|
"sources": {
|
|
25
|
-
"gutenberg": [
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
107
|
+
"gutenberg": [
|
|
108
|
+
"core/template-part:footer",
|
|
109
|
+
"core/social-links"
|
|
110
|
+
],
|
|
111
|
+
"elementor": [
|
|
112
|
+
"elementor/social-icons",
|
|
113
|
+
"elementor/icon-list"
|
|
114
|
+
],
|
|
115
|
+
"divi": [
|
|
116
|
+
"divi/et_pb_social_media_follow"
|
|
117
|
+
],
|
|
118
|
+
"classic": [
|
|
119
|
+
"classic/footer",
|
|
120
|
+
"classic/widget-area"
|
|
121
|
+
]
|
|
29
122
|
},
|
|
30
123
|
"a11y": "One <footer> landmark; each link group is a <nav> named by its title. Social links open in a new tab with rel=me."
|
|
31
124
|
}
|
|
@@ -14,10 +14,10 @@ export interface GalleryItem {
|
|
|
14
14
|
|
|
15
15
|
const gallery = tv({
|
|
16
16
|
slots: {
|
|
17
|
-
heading: 'mb-8 text-2xl font-
|
|
17
|
+
heading: 'mb-8 text-2xl [font-weight:var(--font-weight-heading,700)] tracking-tight md:text-3xl',
|
|
18
18
|
list: 'gap-3 md:gap-4',
|
|
19
19
|
item: 'm-0',
|
|
20
|
-
frame: 'block w-full cursor-zoom-in overflow-hidden rounded-card focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent',
|
|
20
|
+
frame: 'block w-full cursor-zoom-in overflow-hidden rounded-[var(--radius-image,var(--radius-card))] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent',
|
|
21
21
|
thumb: 'h-full w-full object-cover transition-transform duration-300 hover:scale-[1.02]',
|
|
22
22
|
caption: 'mt-2 text-sm opacity-75',
|
|
23
23
|
viewer: 'm-auto max-h-[92vh] max-w-[92vw] bg-transparent p-0 backdrop:bg-ink/85',
|
|
@@ -74,7 +74,7 @@ const sizes = columns === '2' ? '50vw' : columns === '4' ? '(min-width: 768px) 2
|
|
|
74
74
|
{open === 'popover' && (
|
|
75
75
|
<div id={viewer} popover class={s.viewer()}>
|
|
76
76
|
<figure class="m-0 flex flex-col items-center gap-3">
|
|
77
|
-
<KitImage image={item.image} sizes="92vw" class="max-h-[80vh] w-auto rounded-card object-contain" />
|
|
77
|
+
<KitImage image={item.image} sizes="92vw" class="max-h-[80vh] w-auto rounded-[var(--radius-image,var(--radius-card))] object-contain" />
|
|
78
78
|
{item.caption && <figcaption class="text-sm text-surface">{item.caption}</figcaption>}
|
|
79
79
|
<button type="button" popovertarget={viewer} popovertargetaction="hide" class="rounded-md border border-surface/40 px-4 py-2 text-sm text-surface hover:bg-surface/10">{closeLabel}</button>
|
|
80
80
|
</figure>
|
|
@@ -55,7 +55,7 @@ const { root, inner, brand } = header({ layout, tone, position })
|
|
|
55
55
|
{showName && <span>{siteName}</span>}
|
|
56
56
|
</a>
|
|
57
57
|
<div class:list={['flex items-center gap-4', layout === 'centered' ? 'justify-center' : 'ml-auto']}>
|
|
58
|
-
<Nav items={items} label={navLabel} menuLabel={menuLabel} closeLabel={closeLabel} align={layout === 'centered' ? 'center' : 'end'} />
|
|
58
|
+
<Nav items={items} label={navLabel} menuLabel={menuLabel} closeLabel={closeLabel} part="nav-header" align={layout === 'centered' ? 'center' : 'end'} />
|
|
59
59
|
{action && <Button action={action} size="sm" class="hidden md:inline-flex" />}
|
|
60
60
|
</div>
|
|
61
61
|
</div>
|
|
@@ -12,10 +12,10 @@ const hero = tv({
|
|
|
12
12
|
grid: 'grid items-center gap-10',
|
|
13
13
|
copy: 'flex flex-col gap-5',
|
|
14
14
|
eyebrow: 'text-sm font-semibold tracking-wide uppercase opacity-80',
|
|
15
|
-
heading: 'text-4xl font-
|
|
15
|
+
heading: 'text-4xl [font-weight:var(--font-weight-heading,700)] tracking-tight text-balance md:text-5xl lg:text-6xl',
|
|
16
16
|
lead: 'max-w-2xl text-lg text-pretty opacity-85 md:text-xl',
|
|
17
17
|
actions: 'flex flex-wrap gap-3 pt-2',
|
|
18
|
-
media: 'overflow-hidden rounded-card',
|
|
18
|
+
media: 'overflow-hidden rounded-[var(--radius-image,var(--radius-card))]',
|
|
19
19
|
},
|
|
20
20
|
variants: {
|
|
21
21
|
layout: {
|
|
@@ -25,8 +25,13 @@ const hero = tv({
|
|
|
25
25
|
},
|
|
26
26
|
// accent/accent-ink is only ~5:1 — dimming text on it drops body copy under 4.5:1 (WCAG 1.4.3), so accent keeps it at full strength.
|
|
27
27
|
tone: { default: {}, muted: {}, inverse: {}, accent: { eyebrow: 'opacity-100', lead: 'opacity-100' } },
|
|
28
|
+
// Where a cover sets its text over the image. WordPress covers centre it unless told otherwise.
|
|
29
|
+
align: { start: {}, center: {} },
|
|
28
30
|
},
|
|
29
|
-
|
|
31
|
+
compoundVariants: [
|
|
32
|
+
{ layout: 'cover', align: 'center', class: { grid: 'justify-items-center', copy: 'mx-auto items-center text-center', lead: 'mx-auto', actions: 'justify-center' } },
|
|
33
|
+
],
|
|
34
|
+
defaultVariants: { layout: 'split', tone: 'default', align: 'center' },
|
|
30
35
|
})
|
|
31
36
|
|
|
32
37
|
interface Props {
|
|
@@ -38,12 +43,14 @@ interface Props {
|
|
|
38
43
|
actions?: ActionInput[] | undefined
|
|
39
44
|
image?: ImageInput | undefined
|
|
40
45
|
layout?: 'split' | 'centered' | 'cover' | undefined
|
|
46
|
+
/** Text position over a cover image; other layouts ignore it. */
|
|
47
|
+
align?: 'start' | 'center' | undefined
|
|
41
48
|
tone?: 'default' | 'muted' | 'inverse' | 'accent' | undefined
|
|
42
49
|
id?: string | undefined
|
|
43
50
|
}
|
|
44
51
|
|
|
45
|
-
const { heading, level = 1, eyebrow, lead, actions = [], image, layout = 'split', tone, id } = Astro.props
|
|
46
|
-
const s = hero({ layout, tone })
|
|
52
|
+
const { heading, level = 1, eyebrow, lead, actions = [], image, layout = 'split', align, tone, id } = Astro.props
|
|
53
|
+
const s = hero({ layout, align, tone })
|
|
47
54
|
const Heading = level === 1 ? 'h1' : 'h2'
|
|
48
55
|
const headingId = id ? `${id}-title` : undefined
|
|
49
56
|
const cover = layout === 'cover' && image
|
|
@@ -58,7 +65,7 @@ const cover = layout === 'cover' && image
|
|
|
58
65
|
<div class={s.copy()}>
|
|
59
66
|
{eyebrow && <p class={s.eyebrow()}>{eyebrow}</p>}
|
|
60
67
|
<Heading id={headingId} class={s.heading()}>{heading}</Heading>
|
|
61
|
-
{lead && <p class={s.lead()}>{lead}</p>}
|
|
68
|
+
{lead && <p class={s.lead()} data-kit-text>{lead}</p>}
|
|
62
69
|
{actions.length > 0 && <div class={s.actions()}>{actions.map(action => <Button action={action} size="lg" />)}</div>}
|
|
63
70
|
</div>
|
|
64
71
|
</div>
|
|
@@ -71,7 +78,7 @@ const cover = layout === 'cover' && image
|
|
|
71
78
|
<div class={s.copy()}>
|
|
72
79
|
{eyebrow && <p class={s.eyebrow()}>{eyebrow}</p>}
|
|
73
80
|
<Heading id={headingId} class={s.heading()}>{heading}</Heading>
|
|
74
|
-
{lead && <p class={s.lead()}>{lead}</p>}
|
|
81
|
+
{lead && <p class={s.lead()} data-kit-text>{lead}</p>}
|
|
75
82
|
{actions.length > 0 && <div class={s.actions()}>{actions.map(action => <Button action={action} size="lg" />)}</div>}
|
|
76
83
|
</div>
|
|
77
84
|
{image && (
|
|
@@ -66,6 +66,27 @@
|
|
|
66
66
|
}
|
|
67
67
|
}
|
|
68
68
|
},
|
|
69
|
+
{
|
|
70
|
+
"name": "cover-start",
|
|
71
|
+
"props": {
|
|
72
|
+
"heading": "The long way round",
|
|
73
|
+
"lead": "A year of trains across Europe, one station at a time.",
|
|
74
|
+
"layout": "cover",
|
|
75
|
+
"actions": [
|
|
76
|
+
{
|
|
77
|
+
"label": "Read the series",
|
|
78
|
+
"href": "/series/long-way/"
|
|
79
|
+
}
|
|
80
|
+
],
|
|
81
|
+
"image": {
|
|
82
|
+
"src": "/kit-fixtures/photo-3.svg",
|
|
83
|
+
"alt": "",
|
|
84
|
+
"width": 1600,
|
|
85
|
+
"height": 900
|
|
86
|
+
},
|
|
87
|
+
"align": "start"
|
|
88
|
+
}
|
|
89
|
+
},
|
|
69
90
|
{
|
|
70
91
|
"name": "inverse-text-only",
|
|
71
92
|
"props": {
|
|
@@ -29,6 +29,14 @@
|
|
|
29
29
|
],
|
|
30
30
|
"default": "default",
|
|
31
31
|
"description": "Section background (ignored by cover, which is always light text on the image)."
|
|
32
|
+
},
|
|
33
|
+
"align": {
|
|
34
|
+
"options": [
|
|
35
|
+
"start",
|
|
36
|
+
"center"
|
|
37
|
+
],
|
|
38
|
+
"default": "center",
|
|
39
|
+
"description": "Text position over a cover image (layout cover only): centred, as WordPress covers default to, or at the start edge."
|
|
32
40
|
}
|
|
33
41
|
},
|
|
34
42
|
"props": {
|
package/components/nav/Nav.astro
CHANGED
|
@@ -34,15 +34,17 @@ interface Props {
|
|
|
34
34
|
density?: 'compact' | 'comfortable' | undefined
|
|
35
35
|
/** Unique per page when a page has two navigations. */
|
|
36
36
|
id?: string | undefined
|
|
37
|
+
/** What the navigation is, for checks that find a page's parts (`data-cr-part`, e.g. "nav-header"). */
|
|
38
|
+
part?: string | undefined
|
|
37
39
|
}
|
|
38
40
|
|
|
39
|
-
const { items, label = 'Main', menuLabel = 'Menu', closeLabel = 'Close', mobile = 'popover', align, density, id = 'site-nav' } = Astro.props
|
|
41
|
+
const { items, label = 'Main', menuLabel = 'Menu', closeLabel = 'Close', mobile = 'popover', align, density, id = 'site-nav', part } = Astro.props
|
|
40
42
|
const { list, link, sub, toggle } = nav({ align, density })
|
|
41
43
|
const current = Astro.url.pathname
|
|
42
44
|
const drawer = `${id}-drawer`
|
|
43
45
|
---
|
|
44
46
|
{items.length > 0 && (
|
|
45
|
-
<nav aria-label={label} class="relative">
|
|
47
|
+
<nav aria-label={label} data-cr-part={part} class="relative">
|
|
46
48
|
<ul class={list()}>
|
|
47
49
|
{items.map(item => (
|
|
48
50
|
<li class="group relative">
|