@contentrain/astro-kit 0.1.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 +89 -0
- package/catalog.json +1780 -0
- package/components/_shared/Button.astro +37 -0
- package/components/_shared/KitImage.astro +21 -0
- package/components/_shared/Section.astro +43 -0
- package/components/_shared/types.ts +26 -0
- package/components/breadcrumb/Breadcrumb.astro +56 -0
- package/components/breadcrumb/fixtures.json +5 -0
- package/components/breadcrumb/meta.json +27 -0
- package/components/card-grid/CardGrid.astro +82 -0
- package/components/card-grid/fixtures.json +6 -0
- package/components/card-grid/meta.json +123 -0
- package/components/contact-form/ContactForm.astro +79 -0
- package/components/contact-form/fixtures.json +18 -0
- package/components/contact-form/meta.json +28 -0
- package/components/cta/Cta.astro +64 -0
- package/components/cta/fixtures.json +6 -0
- package/components/cta/meta.json +28 -0
- package/components/faq/Faq.astro +79 -0
- package/components/faq/fixtures.json +36 -0
- package/components/faq/meta.json +30 -0
- package/components/footer/Footer.astro +87 -0
- package/components/footer/fixtures.json +5 -0
- package/components/footer/meta.json +31 -0
- package/components/gallery/Gallery.astro +88 -0
- package/components/gallery/fixtures.json +191 -0
- package/components/gallery/meta.json +30 -0
- package/components/header/Header.astro +62 -0
- package/components/header/fixtures.json +125 -0
- package/components/header/meta.json +34 -0
- package/components/hero/Hero.astro +84 -0
- package/components/hero/fixtures.json +95 -0
- package/components/hero/meta.json +127 -0
- package/components/nav/Nav.astro +102 -0
- package/components/nav/fixtures.json +80 -0
- package/components/nav/meta.json +30 -0
- package/components/pagination/Pagination.astro +69 -0
- package/components/pagination/fixtures.json +6 -0
- package/components/pagination/meta.json +31 -0
- package/components/post-card/PostCard.astro +67 -0
- package/components/post-card/fixtures.json +54 -0
- package/components/post-card/meta.json +32 -0
- package/components/prose/Prose.astro +30 -0
- package/components/prose/fixtures.json +4 -0
- package/components/prose/meta.json +69 -0
- package/components/slider/Slider.astro +136 -0
- package/components/slider/fixtures.json +5 -0
- package/components/slider/meta.json +32 -0
- package/components/tabs/Tabs.astro +101 -0
- package/components/tabs/fixtures.json +37 -0
- package/components/tabs/meta.json +28 -0
- package/components/testimonial/Testimonial.astro +70 -0
- package/components/testimonial/fixtures.json +26 -0
- package/components/testimonial/meta.json +97 -0
- package/dist/index.d.mts +150 -0
- package/dist/index.d.mts.map +1 -0
- package/dist/index.mjs +199 -0
- package/dist/index.mjs.map +1 -0
- package/mapping/classic.json +261 -0
- package/mapping/divi.json +295 -0
- package/mapping/elementor.json +322 -0
- package/mapping/gutenberg.json +395 -0
- package/package.json +63 -0
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
[
|
|
2
|
+
{
|
|
3
|
+
"name": "popover",
|
|
4
|
+
"props": {
|
|
5
|
+
"id": "fx-nav-a",
|
|
6
|
+
"items": [
|
|
7
|
+
{
|
|
8
|
+
"label": "Journal",
|
|
9
|
+
"href": "/"
|
|
10
|
+
},
|
|
11
|
+
{
|
|
12
|
+
"label": "Services",
|
|
13
|
+
"href": "/services/",
|
|
14
|
+
"children": [
|
|
15
|
+
{
|
|
16
|
+
"label": "Design",
|
|
17
|
+
"href": "/services/design/"
|
|
18
|
+
},
|
|
19
|
+
{
|
|
20
|
+
"label": "Engineering",
|
|
21
|
+
"href": "/services/engineering/"
|
|
22
|
+
}
|
|
23
|
+
]
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
"label": "About",
|
|
27
|
+
"href": "/about/"
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
"label": "Contact",
|
|
31
|
+
"href": "/contact/"
|
|
32
|
+
}
|
|
33
|
+
]
|
|
34
|
+
}
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
"name": "details-start",
|
|
38
|
+
"props": {
|
|
39
|
+
"id": "fx-nav-b",
|
|
40
|
+
"mobile": "details",
|
|
41
|
+
"align": "start",
|
|
42
|
+
"density": "comfortable",
|
|
43
|
+
"items": [
|
|
44
|
+
{
|
|
45
|
+
"label": "Home",
|
|
46
|
+
"href": "/"
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
"label": "Blog",
|
|
50
|
+
"href": "/blog/"
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
"label": "Contact",
|
|
54
|
+
"href": "/contact/"
|
|
55
|
+
}
|
|
56
|
+
]
|
|
57
|
+
}
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
"name": "center",
|
|
61
|
+
"props": {
|
|
62
|
+
"id": "fx-nav-c",
|
|
63
|
+
"align": "center",
|
|
64
|
+
"items": [
|
|
65
|
+
{
|
|
66
|
+
"label": "Work",
|
|
67
|
+
"href": "/work/"
|
|
68
|
+
},
|
|
69
|
+
{
|
|
70
|
+
"label": "Studio",
|
|
71
|
+
"href": "/studio/"
|
|
72
|
+
},
|
|
73
|
+
{
|
|
74
|
+
"label": "Journal",
|
|
75
|
+
"href": "/journal/"
|
|
76
|
+
}
|
|
77
|
+
]
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
]
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "nav",
|
|
3
|
+
"name": "Navigation",
|
|
4
|
+
"description": "Site navigation: an inline list with hover/focus submenus on wide screens and a toggle on narrow ones that opens a native popover drawer or a <details> dropdown. No JavaScript.",
|
|
5
|
+
"category": "chrome",
|
|
6
|
+
"uses": [],
|
|
7
|
+
"shared": ["types.ts"],
|
|
8
|
+
"variants": {
|
|
9
|
+
"mobile": { "options": ["popover", "details"], "default": "popover", "description": "Narrow-screen menu: a side drawer (Popover API) or a dropdown under the toggle." },
|
|
10
|
+
"align": { "options": ["start", "end", "center"], "default": "end", "description": "Horizontal alignment of the inline list." },
|
|
11
|
+
"density": { "options": ["compact", "comfortable"], "default": "compact", "description": "Gap and text size of the inline list." }
|
|
12
|
+
},
|
|
13
|
+
"props": {
|
|
14
|
+
"items": { "type": "array", "required": true, "items": { "type": "object", "fields": { "label": { "type": "string", "required": true }, "href": { "type": "url", "required": true }, "newTab": { "type": "boolean" }, "children": { "type": "array" } } }, "description": "Menu entries; one level of children is shown." },
|
|
15
|
+
"label": { "type": "string", "description": "Accessible name of the navigation landmark (ui-strings nav.label)." },
|
|
16
|
+
"menuLabel": { "type": "string", "description": "Text of the narrow-screen toggle (ui-strings nav.menu)." },
|
|
17
|
+
"closeLabel": { "type": "string", "description": "Text of the drawer's close button (ui-strings nav.close)." },
|
|
18
|
+
"id": { "type": "string", "description": "Unique id when a page renders two navigations." }
|
|
19
|
+
},
|
|
20
|
+
"slots": {},
|
|
21
|
+
"js": "none",
|
|
22
|
+
"dependencies": {},
|
|
23
|
+
"sources": {
|
|
24
|
+
"gutenberg": ["core/navigation", "core/page-list"],
|
|
25
|
+
"elementor": ["elementor/nav-menu"],
|
|
26
|
+
"divi": ["divi/et_pb_menu", "divi/et_pb_fullwidth_menu"],
|
|
27
|
+
"classic": ["classic/nav-menu"]
|
|
28
|
+
},
|
|
29
|
+
"a11y": "A <nav> landmark with a label; the current page carries aria-current. Submenus open on :focus-within as well as hover. The drawer is a native popover: Escape and light dismiss close it and focus returns to the toggle."
|
|
30
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
---
|
|
2
|
+
// Links between the pages of a list. Addresses follow WordPress — the list
|
|
3
|
+
// itself for page 1, `<base>page/<n>/` after — so a migrated archive keeps
|
|
4
|
+
// its page URLs.
|
|
5
|
+
import { tv } from 'tailwind-variants'
|
|
6
|
+
|
|
7
|
+
const pagination = tv({
|
|
8
|
+
slots: {
|
|
9
|
+
root: 'mt-12 flex flex-wrap items-center gap-1',
|
|
10
|
+
item: 'inline-flex min-w-8 items-center justify-center gap-1.5 rounded-md px-2 py-2 text-sm no-underline sm:min-w-10 sm:px-3',
|
|
11
|
+
link: 'text-ink hover:bg-surface-muted',
|
|
12
|
+
current: 'bg-ink font-semibold text-surface',
|
|
13
|
+
},
|
|
14
|
+
variants: {
|
|
15
|
+
style: {
|
|
16
|
+
numbered: {},
|
|
17
|
+
simple: { root: 'justify-between' },
|
|
18
|
+
},
|
|
19
|
+
align: { start: {}, center: {}, end: {} },
|
|
20
|
+
},
|
|
21
|
+
compoundVariants: [
|
|
22
|
+
{ style: 'numbered', align: 'start', class: { root: 'justify-start' } },
|
|
23
|
+
{ style: 'numbered', align: 'center', class: { root: 'justify-center' } },
|
|
24
|
+
{ style: 'numbered', align: 'end', class: { root: 'justify-end' } },
|
|
25
|
+
],
|
|
26
|
+
defaultVariants: { style: 'numbered', align: 'center' },
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
interface Props {
|
|
30
|
+
/** The list's first-page address, e.g. `/category/news/`. */
|
|
31
|
+
base: string
|
|
32
|
+
current: number
|
|
33
|
+
total: number
|
|
34
|
+
/** Accessible name of the navigation ("Pagination"). */
|
|
35
|
+
label?: string | undefined
|
|
36
|
+
prevLabel?: string | undefined
|
|
37
|
+
nextLabel?: string | undefined
|
|
38
|
+
/** Word read before a page number ("Page"). */
|
|
39
|
+
pageLabel?: string | undefined
|
|
40
|
+
style?: 'numbered' | 'simple' | undefined
|
|
41
|
+
align?: 'start' | 'center' | 'end' | undefined
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const { base, current, total, label = 'Pagination', prevLabel = 'Previous', nextLabel = 'Next', pageLabel = 'Page', style = 'numbered', align } = Astro.props
|
|
45
|
+
const slots = pagination({ style, align })
|
|
46
|
+
const href = (page: number) => (page <= 1 ? base : `${base}page/${page}/`)
|
|
47
|
+
// First, last and two either side of the current page; gaps become an ellipsis.
|
|
48
|
+
const shown = [...new Set([1, current - 2, current - 1, current, current + 1, current + 2, total])]
|
|
49
|
+
.filter(page => page >= 1 && page <= total)
|
|
50
|
+
.toSorted((a, b) => a - b)
|
|
51
|
+
---
|
|
52
|
+
{total > 1 && (
|
|
53
|
+
<nav aria-label={label} class={slots.root()}>
|
|
54
|
+
{current > 1
|
|
55
|
+
? <a href={href(current - 1)} rel="prev" class:list={[slots.item(), slots.link()]}><span aria-hidden="true">←</span> <span class="max-sm:sr-only">{prevLabel}</span></a>
|
|
56
|
+
: style === 'simple' && <span />}
|
|
57
|
+
{style === 'numbered' && shown.map((page, index) => (
|
|
58
|
+
<>
|
|
59
|
+
{index > 0 && page - shown[index - 1]! > 1 && <span class:list={[slots.item(), 'text-ink-muted']} aria-hidden="true">…</span>}
|
|
60
|
+
{/* Two away from the current page: dropped on phones so the row fits one line. */}
|
|
61
|
+
{page === current
|
|
62
|
+
? <span aria-current="page" class:list={[slots.item(), slots.current()]}><span class="sr-only">{pageLabel} </span>{page}</span>
|
|
63
|
+
: <a href={href(page)} class:list={[slots.item(), slots.link(), Math.abs(page - current) === 2 && page !== 1 && page !== total && 'max-md:hidden']}><span class="sr-only">{pageLabel} </span>{page}</a>}
|
|
64
|
+
</>
|
|
65
|
+
))}
|
|
66
|
+
{style === 'simple' && <span class="text-sm text-ink-muted">{pageLabel} {current} / {total}</span>}
|
|
67
|
+
{current < total && <a href={href(current + 1)} rel="next" class:list={[slots.item(), slots.link()]}><span class="max-sm:sr-only">{nextLabel}</span> <span aria-hidden="true">→</span></a>}
|
|
68
|
+
</nav>
|
|
69
|
+
)}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
[
|
|
2
|
+
{ "name": "numbered-middle", "props": { "base": "/blog/", "current": 5, "total": 12 } },
|
|
3
|
+
{ "name": "simple", "props": { "base": "/blog/", "current": 2, "total": 3, "style": "simple" } },
|
|
4
|
+
{ "name": "numbered-start", "props": { "base": "/tag/astro/", "current": 1, "total": 3, "align": "start" } },
|
|
5
|
+
{ "name": "numbered-end", "props": { "base": "/tag/astro/", "current": 3, "total": 3, "align": "end" } }
|
|
6
|
+
]
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "pagination",
|
|
3
|
+
"name": "Pagination",
|
|
4
|
+
"description": "Links between the pages of a post list, at WordPress addresses (<base>page/<n>/).",
|
|
5
|
+
"category": "content",
|
|
6
|
+
"uses": [],
|
|
7
|
+
"shared": [],
|
|
8
|
+
"variants": {
|
|
9
|
+
"style": { "options": ["numbered", "simple"], "default": "numbered", "description": "Numbered pages with ellipses, or previous/next with 'Page n / m'." },
|
|
10
|
+
"align": { "options": ["start", "center", "end"], "default": "center", "description": "Alignment of the numbered style." }
|
|
11
|
+
},
|
|
12
|
+
"props": {
|
|
13
|
+
"base": { "type": "string", "required": true, "description": "The list's first-page address, ending in a slash." },
|
|
14
|
+
"current": { "type": "integer", "required": true, "min": 1, "description": "Current page number." },
|
|
15
|
+
"total": { "type": "integer", "required": true, "min": 1, "description": "Number of pages; nothing renders when it is 1." },
|
|
16
|
+
"label": { "type": "string", "description": "Accessible name (ui-strings pagination.label)." },
|
|
17
|
+
"prevLabel": { "type": "string", "description": "ui-strings pagination.prev." },
|
|
18
|
+
"nextLabel": { "type": "string", "description": "ui-strings pagination.next." },
|
|
19
|
+
"pageLabel": { "type": "string", "description": "ui-strings pagination.page." }
|
|
20
|
+
},
|
|
21
|
+
"slots": {},
|
|
22
|
+
"js": "none",
|
|
23
|
+
"dependencies": {},
|
|
24
|
+
"sources": {
|
|
25
|
+
"gutenberg": ["core/query-pagination"],
|
|
26
|
+
"elementor": ["elementor/posts:pagination"],
|
|
27
|
+
"divi": ["divi/et_pb_blog:pagination"],
|
|
28
|
+
"classic": ["classic/pagination"]
|
|
29
|
+
},
|
|
30
|
+
"a11y": "A labelled <nav>; the current page is aria-current=page; prev/next carry rel. Numbers are prefixed with a visually hidden 'Page'."
|
|
31
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
---
|
|
2
|
+
// One post in a list: image, category, date, title, excerpt and author. The
|
|
3
|
+
// whole card is clickable through its title link.
|
|
4
|
+
import { tv } from 'tailwind-variants'
|
|
5
|
+
import KitImage from '../_shared/KitImage.astro'
|
|
6
|
+
import type { ImageInput, LinkInput } from '../_shared/types'
|
|
7
|
+
|
|
8
|
+
const card = tv({
|
|
9
|
+
slots: {
|
|
10
|
+
root: 'group relative flex gap-4',
|
|
11
|
+
media: 'shrink-0 overflow-hidden rounded-[var(--radius-card)]',
|
|
12
|
+
body: 'flex min-w-0 flex-col gap-2',
|
|
13
|
+
meta: 'flex flex-wrap items-center gap-x-2 text-sm text-ink-muted',
|
|
14
|
+
title: 'leading-snug font-semibold text-balance text-ink',
|
|
15
|
+
excerpt: 'text-ink-muted',
|
|
16
|
+
},
|
|
17
|
+
variants: {
|
|
18
|
+
layout: {
|
|
19
|
+
vertical: { root: 'flex-col', media: 'w-full', title: 'text-xl', excerpt: 'line-clamp-3' },
|
|
20
|
+
horizontal: { root: 'flex-col sm:flex-row sm:items-start', media: 'w-full sm:w-64', title: 'text-xl', excerpt: 'line-clamp-2' },
|
|
21
|
+
minimal: { root: 'flex-col border-b border-line pb-5', media: 'hidden', title: 'text-lg', excerpt: 'line-clamp-2' },
|
|
22
|
+
},
|
|
23
|
+
},
|
|
24
|
+
defaultVariants: { layout: 'vertical' },
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
interface Props {
|
|
28
|
+
title: string
|
|
29
|
+
href: string
|
|
30
|
+
excerpt?: string | undefined
|
|
31
|
+
/** Publication date: a Date, or ISO 8601 text as Contentrain stores it. */
|
|
32
|
+
date?: Date | string | undefined
|
|
33
|
+
/** BCP 47 locale the date is written in (site.language). */
|
|
34
|
+
locale?: string | undefined
|
|
35
|
+
image?: ImageInput | undefined
|
|
36
|
+
category?: LinkInput | undefined
|
|
37
|
+
author?: string | undefined
|
|
38
|
+
headingLevel?: 2 | 3 | undefined
|
|
39
|
+
layout?: 'vertical' | 'horizontal' | 'minimal' | undefined
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const { title, href, excerpt, date: rawDate, locale = 'en', image, category, author, headingLevel = 2, layout } = Astro.props
|
|
43
|
+
const date = rawDate === undefined ? undefined : new Date(rawDate)
|
|
44
|
+
const slots = card({ layout })
|
|
45
|
+
const Heading = `h${headingLevel}` as 'h2' | 'h3'
|
|
46
|
+
---
|
|
47
|
+
<article class={slots.root()}>
|
|
48
|
+
{image && layout !== 'minimal' && (
|
|
49
|
+
<div class={slots.media()}>
|
|
50
|
+
<KitImage image={image} sizes={layout === 'horizontal' ? '256px' : '(min-width: 1024px) 360px, 100vw'} class="aspect-[16/9] w-full object-cover transition duration-300 group-hover:scale-[1.02]" />
|
|
51
|
+
</div>
|
|
52
|
+
)}
|
|
53
|
+
<div class={slots.body()}>
|
|
54
|
+
{(category || date) && (
|
|
55
|
+
<p class={slots.meta()}>
|
|
56
|
+
{category && <a href={category.href} class="relative z-10 font-medium text-accent no-underline hover:underline">{category.label}</a>}
|
|
57
|
+
{category && date && <span aria-hidden="true">·</span>}
|
|
58
|
+
{date && <time datetime={date.toISOString()}>{date.toLocaleDateString(locale, { dateStyle: 'long', timeZone: 'UTC' })}</time>}
|
|
59
|
+
</p>
|
|
60
|
+
)}
|
|
61
|
+
<Heading class={slots.title()}>
|
|
62
|
+
<a href={href} class="text-current no-underline after:absolute after:inset-0 after:content-[''] hover:text-accent">{title}</a>
|
|
63
|
+
</Heading>
|
|
64
|
+
{excerpt && <p class={slots.excerpt()}>{excerpt}</p>}
|
|
65
|
+
{author && <p class="text-sm text-ink-muted">{author}</p>}
|
|
66
|
+
</div>
|
|
67
|
+
</article>
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
[
|
|
2
|
+
{
|
|
3
|
+
"name": "vertical",
|
|
4
|
+
"props": {
|
|
5
|
+
"title": "What a careful migration keeps",
|
|
6
|
+
"href": "/blog/what-a-careful-migration-keeps/",
|
|
7
|
+
"excerpt": "Addresses, formatting and rankings: what a careful WordPress to Astro migration keeps, block by block.",
|
|
8
|
+
"date": "2026-09-28T09:00:00Z",
|
|
9
|
+
"category": {
|
|
10
|
+
"label": "Guides",
|
|
11
|
+
"href": "/category/guides/"
|
|
12
|
+
},
|
|
13
|
+
"author": "Mira Kovač",
|
|
14
|
+
"image": {
|
|
15
|
+
"src": "/kit-fixtures/photo-1.svg",
|
|
16
|
+
"alt": "",
|
|
17
|
+
"width": 1200,
|
|
18
|
+
"height": 800
|
|
19
|
+
}
|
|
20
|
+
},
|
|
21
|
+
"frame": "narrow"
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
"name": "horizontal",
|
|
25
|
+
"props": {
|
|
26
|
+
"layout": "horizontal",
|
|
27
|
+
"title": "Designing with fewer requests",
|
|
28
|
+
"href": "/blog/designing-with-fewer-requests/",
|
|
29
|
+
"excerpt": "One stylesheet, no runtime, images sized at build time.",
|
|
30
|
+
"date": "2026-09-26T09:00:00Z",
|
|
31
|
+
"image": {
|
|
32
|
+
"src": "/kit-fixtures/photo-2.svg",
|
|
33
|
+
"alt": "",
|
|
34
|
+
"width": 1200,
|
|
35
|
+
"height": 800
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
"name": "minimal",
|
|
41
|
+
"props": {
|
|
42
|
+
"layout": "minimal",
|
|
43
|
+
"headingLevel": 3,
|
|
44
|
+
"title": "Redirects: the unglamorous hero",
|
|
45
|
+
"href": "/blog/redirects/",
|
|
46
|
+
"excerpt": "Keep every address. Redirect the ones you cannot keep.",
|
|
47
|
+
"date": "2026-09-16T09:00:00Z",
|
|
48
|
+
"category": {
|
|
49
|
+
"label": "News",
|
|
50
|
+
"href": "/category/news/"
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
]
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "post-card",
|
|
3
|
+
"name": "Post card",
|
|
4
|
+
"description": "One post in a list (index, archive, related posts): image, category, date, title, excerpt, author. The whole card links to the post.",
|
|
5
|
+
"category": "content",
|
|
6
|
+
"uses": [],
|
|
7
|
+
"shared": ["KitImage.astro", "types.ts"],
|
|
8
|
+
"variants": {
|
|
9
|
+
"layout": { "options": ["vertical", "horizontal", "minimal"], "default": "vertical", "description": "Image on top, image beside the text, or text only with a divider." }
|
|
10
|
+
},
|
|
11
|
+
"props": {
|
|
12
|
+
"title": { "type": "string", "required": true, "description": "Post title (posts.title)." },
|
|
13
|
+
"href": { "type": "url", "required": true, "description": "Post address (from the permalink pattern)." },
|
|
14
|
+
"excerpt": { "type": "text", "description": "Summary (posts.excerpt)." },
|
|
15
|
+
"date": { "type": "datetime", "description": "Publication date (posts.published_at)." },
|
|
16
|
+
"locale": { "type": "string", "description": "Locale the date is written in (site.language)." },
|
|
17
|
+
"image": { "type": "object", "fields": { "src": { "type": "image", "required": true }, "alt": { "type": "string", "required": true }, "width": { "type": "integer" }, "height": { "type": "integer" } }, "description": "Cover (posts.cover → media)." },
|
|
18
|
+
"category": { "type": "object", "fields": { "label": { "type": "string", "required": true }, "href": { "type": "url", "required": true } }, "description": "Primary category and its archive." },
|
|
19
|
+
"author": { "type": "string", "description": "Author name (posts.author → authors.name)." },
|
|
20
|
+
"headingLevel": { "type": "integer", "min": 2, "max": 3, "default": 2, "description": "Title heading level in the page outline." }
|
|
21
|
+
},
|
|
22
|
+
"slots": {},
|
|
23
|
+
"js": "none",
|
|
24
|
+
"dependencies": {},
|
|
25
|
+
"sources": {
|
|
26
|
+
"gutenberg": ["core/query", "core/post-template", "core/latest-posts"],
|
|
27
|
+
"elementor": ["elementor/posts", "elementor/archive-posts"],
|
|
28
|
+
"divi": ["divi/et_pb_blog"],
|
|
29
|
+
"classic": ["classic/archive-item"]
|
|
30
|
+
},
|
|
31
|
+
"a11y": "An <article> with one link (the title), stretched over the card; the category link sits above it and stays separately focusable. Dates are <time datetime>."
|
|
32
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
---
|
|
2
|
+
// A rich-text body as the editor wrote it. A migrated post keeps the markup
|
|
3
|
+
// WordPress rendered — `wp-block-*` classes, alignments, preset colors — and
|
|
4
|
+
// is styled by Tailwind Typography bound to the site's tokens (`prose-site`)
|
|
5
|
+
// plus the Gutenberg block styles in the starter's styles/wp-blocks.css. The
|
|
6
|
+
// body is the site's own content from its own repository, so it is rendered
|
|
7
|
+
// as HTML. Only post and page bodies carry `data-pagefind-body`, so search
|
|
8
|
+
// indexes content, not chrome.
|
|
9
|
+
import { tv } from 'tailwind-variants'
|
|
10
|
+
|
|
11
|
+
const prose = tv({
|
|
12
|
+
base: 'prose prose-site max-w-none',
|
|
13
|
+
variants: {
|
|
14
|
+
size: { base: 'prose-base', lg: 'prose-lg' },
|
|
15
|
+
},
|
|
16
|
+
defaultVariants: { size: 'lg' },
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
interface Props {
|
|
20
|
+
/** Rendered HTML of a richtext field. */
|
|
21
|
+
html: string
|
|
22
|
+
/** Index this body for site search. */
|
|
23
|
+
searchable?: boolean | undefined
|
|
24
|
+
class?: string | undefined
|
|
25
|
+
size?: 'base' | 'lg' | undefined
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const { html, searchable = true, class: className, size } = Astro.props
|
|
29
|
+
---
|
|
30
|
+
<div class={prose({ size, class: className })} data-pagefind-body={searchable ? '' : undefined} set:html={html} />
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
[
|
|
2
|
+
{ "name": "lg", "frame": "narrow", "props": { "html": "<h2 class=\"wp-block-heading\">Keeping what works</h2><p>A careful migration keeps <strong>addresses</strong>, <a href=\"/about/\">formatting</a> and rankings.</p><ul class=\"wp-block-list\"><li>Permalinks stay put.</li><li>Archives survive.</li></ul><blockquote class=\"wp-block-quote\"><p>The fastest request is the one the server never has to think about.</p><cite>Every performance engineer</cite></blockquote><div class=\"wp-block-buttons is-layout-flex\"><div class=\"wp-block-button\"><a class=\"wp-block-button__link wp-element-button\" href=\"/contact/\">Get in touch</a></div></div>" } },
|
|
3
|
+
{ "name": "base", "frame": "narrow", "props": { "size": "base", "searchable": false, "html": "<p class=\"has-drop-cap\">Moving a WordPress site to a static stack is mostly about keeping what already works.</p><figure class=\"wp-block-table is-style-stripes\"><table><thead><tr><th>Metric</th><th>Before</th><th>After</th></tr></thead><tbody><tr><td>Requests</td><td>84</td><td>9</td></tr><tr><td>LCP</td><td>3.1 s</td><td>0.8 s</td></tr></tbody></table></figure>" } }
|
|
4
|
+
]
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "prose",
|
|
3
|
+
"name": "Prose",
|
|
4
|
+
"description": "A rich-text body (post or page content) rendered as the editor wrote it: WordPress block markup kept, styled by Tailwind Typography on the site's tokens and the starter's Gutenberg block styles. The fallback for every builder element no rule maps to a component. Container blocks (core/group, core/columns) are not sources: their children are matched instead. Needs the starter's global.css (prose-site utility, wp-blocks.css).",
|
|
5
|
+
"category": "content",
|
|
6
|
+
"uses": [],
|
|
7
|
+
"shared": [],
|
|
8
|
+
"variants": {
|
|
9
|
+
"size": {
|
|
10
|
+
"options": [
|
|
11
|
+
"base",
|
|
12
|
+
"lg"
|
|
13
|
+
],
|
|
14
|
+
"default": "lg",
|
|
15
|
+
"description": "Type scale of the body."
|
|
16
|
+
}
|
|
17
|
+
},
|
|
18
|
+
"props": {
|
|
19
|
+
"html": {
|
|
20
|
+
"type": "richtext",
|
|
21
|
+
"required": true,
|
|
22
|
+
"description": "Rendered HTML of a richtext field (posts.body, pages.body)."
|
|
23
|
+
},
|
|
24
|
+
"searchable": {
|
|
25
|
+
"type": "boolean",
|
|
26
|
+
"default": true,
|
|
27
|
+
"description": "Mark the body for the site search index (data-pagefind-body)."
|
|
28
|
+
}
|
|
29
|
+
},
|
|
30
|
+
"slots": {},
|
|
31
|
+
"js": "none",
|
|
32
|
+
"dependencies": {},
|
|
33
|
+
"sources": {
|
|
34
|
+
"gutenberg": [
|
|
35
|
+
"core/paragraph",
|
|
36
|
+
"core/heading",
|
|
37
|
+
"core/list",
|
|
38
|
+
"core/list-item",
|
|
39
|
+
"core/quote",
|
|
40
|
+
"core/pullquote",
|
|
41
|
+
"core/image",
|
|
42
|
+
"core/table",
|
|
43
|
+
"core/code",
|
|
44
|
+
"core/preformatted",
|
|
45
|
+
"core/verse",
|
|
46
|
+
"core/separator",
|
|
47
|
+
"core/spacer",
|
|
48
|
+
"core/embed",
|
|
49
|
+
"core/video",
|
|
50
|
+
"core/audio",
|
|
51
|
+
"core/file",
|
|
52
|
+
"core/html",
|
|
53
|
+
"core/freeform",
|
|
54
|
+
"core/post-content"
|
|
55
|
+
],
|
|
56
|
+
"elementor": [
|
|
57
|
+
"elementor/text-editor",
|
|
58
|
+
"elementor/theme-post-content"
|
|
59
|
+
],
|
|
60
|
+
"divi": [
|
|
61
|
+
"divi/et_pb_text",
|
|
62
|
+
"divi/et_pb_post_content"
|
|
63
|
+
],
|
|
64
|
+
"classic": [
|
|
65
|
+
"classic/the-content"
|
|
66
|
+
]
|
|
67
|
+
},
|
|
68
|
+
"a11y": "Keeps the author's heading levels and list/table semantics; embedded media keep their own titles and captions. Block styles give focus-visible links and readable measure."
|
|
69
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
---
|
|
2
|
+
// A carousel of images with optional captions. Without JavaScript it is a
|
|
3
|
+
// horizontally scrolling, snapping row — every slide is reachable. With it,
|
|
4
|
+
// Embla turns the row into a paged carousel with previous/next buttons and
|
|
5
|
+
// slide dots.
|
|
6
|
+
import { tv } from 'tailwind-variants'
|
|
7
|
+
import Button from '../_shared/Button.astro'
|
|
8
|
+
import KitImage from '../_shared/KitImage.astro'
|
|
9
|
+
import type { ActionInput, ImageInput } from '../_shared/types'
|
|
10
|
+
|
|
11
|
+
const slider = tv({
|
|
12
|
+
slots: {
|
|
13
|
+
viewport: 'flex snap-x snap-mandatory gap-4 overflow-x-auto scroll-smooth group-data-[ready]:snap-none group-data-[ready]:overflow-hidden [scrollbar-width:none]',
|
|
14
|
+
slide: 'relative min-w-0 shrink-0 snap-start overflow-hidden',
|
|
15
|
+
image: 'w-full object-cover',
|
|
16
|
+
caption: 'flex flex-col items-start gap-2',
|
|
17
|
+
},
|
|
18
|
+
variants: {
|
|
19
|
+
perView: {
|
|
20
|
+
'1': { slide: 'basis-full' },
|
|
21
|
+
'2': { slide: 'basis-[85%] sm:basis-[calc(50%-0.5rem)]' },
|
|
22
|
+
'3': { slide: 'basis-[85%] sm:basis-[calc(50%-0.5rem)] lg:basis-[calc(33.333%-0.667rem)]' },
|
|
23
|
+
},
|
|
24
|
+
aspect: {
|
|
25
|
+
wide: { image: 'aspect-[4/3] md:aspect-[21/9]' },
|
|
26
|
+
standard: { image: 'aspect-[3/2]' },
|
|
27
|
+
square: { image: 'aspect-square' },
|
|
28
|
+
},
|
|
29
|
+
caption: {
|
|
30
|
+
below: { image: 'rounded-[var(--radius-card)]', caption: 'pt-4' },
|
|
31
|
+
// Over the image on wide screens; under it on phones, where the image is too short to hold text.
|
|
32
|
+
overlay: { image: 'rounded-[var(--radius-card)] md:rounded-none', slide: 'md:rounded-[var(--radius-card)]', caption: 'pt-4 md:absolute md:inset-x-0 md:bottom-0 md:bg-gradient-to-t md:from-ink/80 md:to-transparent md:p-6 md:pt-16 md:text-surface' },
|
|
33
|
+
},
|
|
34
|
+
},
|
|
35
|
+
defaultVariants: { perView: '1', aspect: 'wide', caption: 'overlay' },
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
interface Slide {
|
|
39
|
+
image: ImageInput
|
|
40
|
+
title?: string | undefined
|
|
41
|
+
text?: string | undefined
|
|
42
|
+
action?: ActionInput | undefined
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
interface Props {
|
|
46
|
+
slides: Slide[]
|
|
47
|
+
/** Accessible name of the carousel ("Featured projects"). */
|
|
48
|
+
label: string
|
|
49
|
+
prevLabel?: string | undefined
|
|
50
|
+
nextLabel?: string | undefined
|
|
51
|
+
/** Word before a slide number in dot labels and slide names ("Slide"). */
|
|
52
|
+
slideLabel?: string | undefined
|
|
53
|
+
/** Word between a slide number and the count ("of"). */
|
|
54
|
+
ofLabel?: string | undefined
|
|
55
|
+
loop?: boolean | undefined
|
|
56
|
+
perView?: '1' | '2' | '3' | undefined
|
|
57
|
+
aspect?: 'wide' | 'standard' | 'square' | undefined
|
|
58
|
+
caption?: 'below' | 'overlay' | undefined
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const { slides, label, prevLabel = 'Previous slide', nextLabel = 'Next slide', slideLabel = 'Slide', ofLabel = 'of', loop = false, perView = '1', aspect, caption } = Astro.props
|
|
62
|
+
const slots = slider({ perView, aspect, caption })
|
|
63
|
+
const sizes = perView === '1' ? '(min-width: 1216px) 1152px, 100vw' : perView === '2' ? '(min-width: 640px) 50vw, 85vw' : '(min-width: 1024px) 33vw, (min-width: 640px) 50vw, 85vw'
|
|
64
|
+
// Arrows from sm up; on phones the carousel is swiped and the dots show the position.
|
|
65
|
+
const arrow = 'absolute top-1/2 z-10 hidden size-11 -translate-y-1/2 items-center justify-center rounded-full bg-surface/90 text-ink shadow-md ring-1 ring-line hover:bg-surface disabled:opacity-40 sm:group-data-[ready]:flex'
|
|
66
|
+
---
|
|
67
|
+
<section class="group container-page relative py-8" aria-roledescription="carousel" aria-label={label} data-kit-slider data-loop={loop ? '' : undefined} data-slide-label={slideLabel}>
|
|
68
|
+
<div class={slots.viewport()} data-viewport>
|
|
69
|
+
<div class="flex gap-4 [&>*]:shrink-0 group-data-[ready]:[&>*]:snap-align-none" data-container>
|
|
70
|
+
{slides.map((slide, index) => (
|
|
71
|
+
<div class={slots.slide()} role="group" aria-roledescription="slide" aria-label={`${slideLabel} ${index + 1} ${ofLabel} ${slides.length}`}>
|
|
72
|
+
<KitImage image={slide.image} sizes={sizes} priority={index === 0} class={slots.image()} />
|
|
73
|
+
{(slide.title || slide.text || slide.action) && (
|
|
74
|
+
<div class={slots.caption()}>
|
|
75
|
+
{slide.title && <p class="text-xl font-semibold text-balance">{slide.title}</p>}
|
|
76
|
+
{slide.text && <p class="opacity-85">{slide.text}</p>}
|
|
77
|
+
{slide.action && <Button action={slide.action} size="sm" class="mt-2" />}
|
|
78
|
+
</div>
|
|
79
|
+
)}
|
|
80
|
+
</div>
|
|
81
|
+
))}
|
|
82
|
+
</div>
|
|
83
|
+
</div>
|
|
84
|
+
{slides.length > 1 && (
|
|
85
|
+
<>
|
|
86
|
+
<button type="button" class:list={[arrow, 'left-6 md:left-10']} aria-label={prevLabel} data-prev>
|
|
87
|
+
<svg aria-hidden="true" viewBox="0 0 24 24" class="size-5" fill="none" stroke="currentColor" stroke-width="2"><path d="M15 18l-6-6 6-6" /></svg>
|
|
88
|
+
</button>
|
|
89
|
+
<button type="button" class:list={[arrow, 'right-6 md:right-10']} aria-label={nextLabel} data-next>
|
|
90
|
+
<svg aria-hidden="true" viewBox="0 0 24 24" class="size-5" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 6l6 6-6 6" /></svg>
|
|
91
|
+
</button>
|
|
92
|
+
<div class="mt-4 hidden justify-center group-data-[ready]:flex" data-dots></div>
|
|
93
|
+
</>
|
|
94
|
+
)}
|
|
95
|
+
</section>
|
|
96
|
+
|
|
97
|
+
<script>
|
|
98
|
+
import EmblaCarousel from 'embla-carousel'
|
|
99
|
+
|
|
100
|
+
for (const root of document.querySelectorAll<HTMLElement>('[data-kit-slider]')) {
|
|
101
|
+
const viewport = root.querySelector<HTMLElement>('[data-viewport]')
|
|
102
|
+
if (!viewport || root.dataset.ready !== undefined) continue
|
|
103
|
+
root.dataset.ready = ''
|
|
104
|
+
// Reduced motion: slides change instantly instead of gliding.
|
|
105
|
+
const reduce = matchMedia('(prefers-reduced-motion: reduce)').matches
|
|
106
|
+
const embla = EmblaCarousel(viewport, { loop: root.dataset.loop !== undefined, align: 'start', containScroll: 'trimSnaps', duration: reduce ? 0 : 25 })
|
|
107
|
+
const prev = root.querySelector<HTMLButtonElement>('[data-prev]')
|
|
108
|
+
const next = root.querySelector<HTMLButtonElement>('[data-next]')
|
|
109
|
+
const dots = root.querySelector<HTMLElement>('[data-dots]')
|
|
110
|
+
prev?.addEventListener('click', () => embla.scrollPrev())
|
|
111
|
+
next?.addEventListener('click', () => embla.scrollNext())
|
|
112
|
+
|
|
113
|
+
const label = root.dataset.slideLabel ?? 'Slide'
|
|
114
|
+
const buttons = embla.scrollSnapList().map((_, index) => {
|
|
115
|
+
const dot = document.createElement('button')
|
|
116
|
+
dot.type = 'button'
|
|
117
|
+
// A 24px target around a 10px dot (WCAG 2.5.8): on phones the dots are the only controls.
|
|
118
|
+
dot.className = 'group/dot grid size-6 place-items-center rounded-full'
|
|
119
|
+
const mark = document.createElement('span')
|
|
120
|
+
mark.className = 'size-2.5 rounded-full bg-ink/25 group-aria-[current=true]/dot:bg-ink'
|
|
121
|
+
dot.append(mark)
|
|
122
|
+
dot.setAttribute('aria-label', `${label} ${index + 1}`)
|
|
123
|
+
dot.addEventListener('click', () => embla.scrollTo(index))
|
|
124
|
+
dots?.append(dot)
|
|
125
|
+
return dot
|
|
126
|
+
})
|
|
127
|
+
const sync = () => {
|
|
128
|
+
const selected = embla.selectedScrollSnap()
|
|
129
|
+
buttons.forEach((dot, index) => dot.setAttribute('aria-current', String(index === selected)))
|
|
130
|
+
if (prev) prev.disabled = !embla.canScrollPrev()
|
|
131
|
+
if (next) next.disabled = !embla.canScrollNext()
|
|
132
|
+
}
|
|
133
|
+
embla.on('select', sync).on('reInit', sync)
|
|
134
|
+
sync()
|
|
135
|
+
}
|
|
136
|
+
</script>
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
[
|
|
2
|
+
{ "name": "single-wide-overlay", "props": { "label": "Featured work", "slides": [{ "image": { "src": "/kit-fixtures/photo-1.svg", "alt": "Blue gradient", "width": 1200, "height": 800 }, "title": "Northwind Journal", "text": "A WordPress blog moved to Astro in an afternoon.", "action": { "label": "Read the case", "href": "/work/northwind/" } }, { "image": { "src": "/kit-fixtures/photo-2.svg", "alt": "Green gradient", "width": 1200, "height": 800 }, "title": "Harbor & Co" }, { "image": { "src": "/kit-fixtures/photo-3.svg", "alt": "Orange gradient", "width": 1200, "height": 800 }, "title": "Field Notes" }] } },
|
|
3
|
+
{ "name": "two-standard-below", "props": { "label": "Gallery", "perView": "2", "aspect": "standard", "caption": "below", "loop": true, "slides": [{ "image": { "src": "/kit-fixtures/photo-4.svg", "alt": "", "width": 1200, "height": 800 }, "title": "Studio" }, { "image": { "src": "/kit-fixtures/photo-5.svg", "alt": "", "width": 1200, "height": 800 }, "title": "Workshop" }, { "image": { "src": "/kit-fixtures/photo-6.svg", "alt": "", "width": 1200, "height": 800 }, "title": "Garden" }] } },
|
|
4
|
+
{ "name": "three-square", "props": { "label": "Team", "perView": "3", "aspect": "square", "slides": [{ "image": { "src": "/kit-fixtures/avatar-1.svg", "alt": "Mira", "width": 96, "height": 96 } }, { "image": { "src": "/kit-fixtures/avatar-2.svg", "alt": "Jon", "width": 96, "height": 96 } }, { "image": { "src": "/kit-fixtures/avatar-3.svg", "alt": "Lea", "width": 96, "height": 96 } }, { "image": { "src": "/kit-fixtures/photo-1.svg", "alt": "Studio", "width": 1200, "height": 800 } }] } }
|
|
5
|
+
]
|