ferst-core 0.6.2 → 0.7.2
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/bin/ferst-update.mjs +73 -2
- package/components/BlockRenderer.astro +107 -103
- package/components/blocks/Prose.astro +56 -39
- package/components/recipes/ContactCard.astro +172 -170
- package/components/recipes/Documents.astro +146 -0
- package/components/recipes/Hero.astro +447 -191
- package/components/recipes/Tiles.astro +285 -252
- package/components/recipes/Video.astro +157 -0
- package/content/blocks.ts +775 -719
- package/content/cookies.ts +112 -103
- package/examples/primitives.ts +28 -28
- package/examples/recipes.ts +37 -32
- package/lib/documents.ts +62 -0
- package/lib/inline-markdown.ts +65 -52
- package/lib/prose.ts +29 -0
- package/lib/video.ts +75 -0
- package/package.json +1 -1
package/content/cookies.ts
CHANGED
|
@@ -1,103 +1,112 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Cookie / third-party-storage registry + a scanner that reports which uses are
|
|
3
|
-
* actually active on a given site. This powers the auto-generated cookie policy
|
|
4
|
-
* page: its categories and items are DERIVED from the recipes/templates the site
|
|
5
|
-
* uses — functionality-driven, never hand-written or client-specific.
|
|
6
|
-
*
|
|
7
|
-
* Two canonical categories:
|
|
8
|
-
* • necessary — the site's OWN functional storage (no third parties). Always
|
|
9
|
-
* on; can't be switched off. Present on every site.
|
|
10
|
-
* • additional — third-party embeds that may set the provider's cookies when
|
|
11
|
-
* they load. Gated behind the site's shared embed consent, and
|
|
12
|
-
* listed only when a recipe/template that uses them is present.
|
|
13
|
-
*
|
|
14
|
-
* Adding a new cookie-using recipe = one entry here (its `blocks` and/or a
|
|
15
|
-
* detection flag); the policy page then lists it automatically wherever it's used.
|
|
16
|
-
* Pure + framework-free so it's unit-testable without a render.
|
|
17
|
-
*/
|
|
18
|
-
|
|
19
|
-
export type CookieCategory = 'necessary' | 'additional';
|
|
20
|
-
|
|
21
|
-
export interface CookieUse {
|
|
22
|
-
/** Stable id (also the consent grouping key in future per-item models). */
|
|
23
|
-
id: string;
|
|
24
|
-
category: CookieCategory;
|
|
25
|
-
/** Human name shown on the policy page, e.g. "Google Maps". */
|
|
26
|
-
name: string;
|
|
27
|
-
/** Third-party provider, when applicable. */
|
|
28
|
-
provider?: string;
|
|
29
|
-
/** One-line plain-English purpose. */
|
|
30
|
-
purpose: string;
|
|
31
|
-
/** What is stored and where. */
|
|
32
|
-
storage: string;
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
/** Additional uses carry detection rules; stripped before returning to the view. */
|
|
36
|
-
interface AdditionalUse extends CookieUse {
|
|
37
|
-
/** Present if any of these block `type`s appears anywhere on the site. */
|
|
38
|
-
blocks?: string[];
|
|
39
|
-
/** Present if the site has a Google Calendar configured (a dedicated page). */
|
|
40
|
-
calendar?: boolean;
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
/** The site's own functional storage — always present, always on. */
|
|
44
|
-
const NECESSARY: readonly CookieUse[] = [
|
|
45
|
-
{
|
|
46
|
-
id: 'consent-choice',
|
|
47
|
-
category: 'necessary',
|
|
48
|
-
name: 'Cookie preference',
|
|
49
|
-
purpose: 'Remembers whether you allowed or declined additional content, so you are not asked again.',
|
|
50
|
-
storage: 'Stored on your device only (local storage), never shared.',
|
|
51
|
-
},
|
|
52
|
-
{
|
|
53
|
-
id: 'theme',
|
|
54
|
-
category: 'necessary',
|
|
55
|
-
name: 'Appearance',
|
|
56
|
-
purpose: 'Remembers your light or dark theme choice.',
|
|
57
|
-
storage: 'Stored on your device only (local storage), never shared.',
|
|
58
|
-
},
|
|
59
|
-
];
|
|
60
|
-
|
|
61
|
-
/** Third-party embeds — listed only when a site actually uses them. */
|
|
62
|
-
const ADDITIONAL: readonly AdditionalUse[] = [
|
|
63
|
-
{
|
|
64
|
-
id: 'google-maps',
|
|
65
|
-
category: 'additional',
|
|
66
|
-
name: 'Google Maps',
|
|
67
|
-
provider: 'Google',
|
|
68
|
-
purpose: 'Shows embedded maps of where to find us. Google may set cookies when a map loads.',
|
|
69
|
-
storage: 'Third-party cookies set by Google.',
|
|
70
|
-
blocks: ['locations', 'mapEmbed'],
|
|
71
|
-
},
|
|
72
|
-
{
|
|
73
|
-
id: 'google-calendar',
|
|
74
|
-
category: 'additional',
|
|
75
|
-
name: 'Google Calendar',
|
|
76
|
-
provider: 'Google',
|
|
77
|
-
purpose: 'Shows the parish calendar. Google may set cookies when the calendar loads.',
|
|
78
|
-
storage: 'Third-party cookies set by Google.',
|
|
79
|
-
blocks: ['calendar'],
|
|
80
|
-
calendar: true,
|
|
81
|
-
},
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
/**
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Cookie / third-party-storage registry + a scanner that reports which uses are
|
|
3
|
+
* actually active on a given site. This powers the auto-generated cookie policy
|
|
4
|
+
* page: its categories and items are DERIVED from the recipes/templates the site
|
|
5
|
+
* uses — functionality-driven, never hand-written or client-specific.
|
|
6
|
+
*
|
|
7
|
+
* Two canonical categories:
|
|
8
|
+
* • necessary — the site's OWN functional storage (no third parties). Always
|
|
9
|
+
* on; can't be switched off. Present on every site.
|
|
10
|
+
* • additional — third-party embeds that may set the provider's cookies when
|
|
11
|
+
* they load. Gated behind the site's shared embed consent, and
|
|
12
|
+
* listed only when a recipe/template that uses them is present.
|
|
13
|
+
*
|
|
14
|
+
* Adding a new cookie-using recipe = one entry here (its `blocks` and/or a
|
|
15
|
+
* detection flag); the policy page then lists it automatically wherever it's used.
|
|
16
|
+
* Pure + framework-free so it's unit-testable without a render.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
export type CookieCategory = 'necessary' | 'additional';
|
|
20
|
+
|
|
21
|
+
export interface CookieUse {
|
|
22
|
+
/** Stable id (also the consent grouping key in future per-item models). */
|
|
23
|
+
id: string;
|
|
24
|
+
category: CookieCategory;
|
|
25
|
+
/** Human name shown on the policy page, e.g. "Google Maps". */
|
|
26
|
+
name: string;
|
|
27
|
+
/** Third-party provider, when applicable. */
|
|
28
|
+
provider?: string;
|
|
29
|
+
/** One-line plain-English purpose. */
|
|
30
|
+
purpose: string;
|
|
31
|
+
/** What is stored and where. */
|
|
32
|
+
storage: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Additional uses carry detection rules; stripped before returning to the view. */
|
|
36
|
+
interface AdditionalUse extends CookieUse {
|
|
37
|
+
/** Present if any of these block `type`s appears anywhere on the site. */
|
|
38
|
+
blocks?: string[];
|
|
39
|
+
/** Present if the site has a Google Calendar configured (a dedicated page). */
|
|
40
|
+
calendar?: boolean;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** The site's own functional storage — always present, always on. */
|
|
44
|
+
const NECESSARY: readonly CookieUse[] = [
|
|
45
|
+
{
|
|
46
|
+
id: 'consent-choice',
|
|
47
|
+
category: 'necessary',
|
|
48
|
+
name: 'Cookie preference',
|
|
49
|
+
purpose: 'Remembers whether you allowed or declined additional content, so you are not asked again.',
|
|
50
|
+
storage: 'Stored on your device only (local storage), never shared.',
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
id: 'theme',
|
|
54
|
+
category: 'necessary',
|
|
55
|
+
name: 'Appearance',
|
|
56
|
+
purpose: 'Remembers your light or dark theme choice.',
|
|
57
|
+
storage: 'Stored on your device only (local storage), never shared.',
|
|
58
|
+
},
|
|
59
|
+
];
|
|
60
|
+
|
|
61
|
+
/** Third-party embeds — listed only when a site actually uses them. */
|
|
62
|
+
const ADDITIONAL: readonly AdditionalUse[] = [
|
|
63
|
+
{
|
|
64
|
+
id: 'google-maps',
|
|
65
|
+
category: 'additional',
|
|
66
|
+
name: 'Google Maps',
|
|
67
|
+
provider: 'Google',
|
|
68
|
+
purpose: 'Shows embedded maps of where to find us. Google may set cookies when a map loads.',
|
|
69
|
+
storage: 'Third-party cookies set by Google.',
|
|
70
|
+
blocks: ['locations', 'mapEmbed'],
|
|
71
|
+
},
|
|
72
|
+
{
|
|
73
|
+
id: 'google-calendar',
|
|
74
|
+
category: 'additional',
|
|
75
|
+
name: 'Google Calendar',
|
|
76
|
+
provider: 'Google',
|
|
77
|
+
purpose: 'Shows the parish calendar. Google may set cookies when the calendar loads.',
|
|
78
|
+
storage: 'Third-party cookies set by Google.',
|
|
79
|
+
blocks: ['calendar'],
|
|
80
|
+
calendar: true,
|
|
81
|
+
},
|
|
82
|
+
{
|
|
83
|
+
id: 'video',
|
|
84
|
+
category: 'additional',
|
|
85
|
+
name: 'Embedded video (YouTube / Vimeo)',
|
|
86
|
+
provider: 'Google (YouTube) or Vimeo',
|
|
87
|
+
purpose: 'Plays videos embedded on our pages. The provider may set cookies when a video loads.',
|
|
88
|
+
storage: 'Third-party cookies set by the video provider.',
|
|
89
|
+
blocks: ['video'],
|
|
90
|
+
},
|
|
91
|
+
];
|
|
92
|
+
|
|
93
|
+
export interface CookieScanInput {
|
|
94
|
+
/** Every block `type` that appears anywhere on the site. */
|
|
95
|
+
blockTypes: Iterable<string>;
|
|
96
|
+
/** True when the site has a Google Calendar configured (dedicated calendar page). */
|
|
97
|
+
calendarConfigured?: boolean;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export interface CookiePolicyModel {
|
|
101
|
+
necessary: CookieUse[];
|
|
102
|
+
additional: CookieUse[];
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Report the cookie uses actually active on a site, grouped by category. */
|
|
106
|
+
export function scanCookieUses(input: CookieScanInput): CookiePolicyModel {
|
|
107
|
+
const types = new Set(input.blockTypes);
|
|
108
|
+
const additional = ADDITIONAL.filter(
|
|
109
|
+
(u) => (u.blocks?.some((b) => types.has(b)) ?? false) || (u.calendar === true && !!input.calendarConfigured),
|
|
110
|
+
).map(({ blocks, calendar, ...use }) => use); // drop detection fields from the view model
|
|
111
|
+
return { necessary: NECESSARY.map((u) => ({ ...u })), additional };
|
|
112
|
+
}
|
package/examples/primitives.ts
CHANGED
|
@@ -1,28 +1,28 @@
|
|
|
1
|
-
/** One catalogue entry: a block type, a short description, and a live example. */
|
|
2
|
-
export interface GalleryEntry {
|
|
3
|
-
type: string;
|
|
4
|
-
description?: string;
|
|
5
|
-
block: Record<string, unknown>;
|
|
6
|
-
}
|
|
7
|
-
|
|
8
|
-
export const primitives: GalleryEntry[] = [
|
|
9
|
-
{ type: 'heading', description: 'A section title, levels 1–6, optionally aligned.', block: { type: 'heading', text: 'A heading', level: 2 } },
|
|
10
|
-
{ type: 'heading', description: 'Optional gradient text — brand → derived accent-2 (falls back to solid ink where unsupported).', block: { type: 'heading', text: 'A gradient heading', level: 2, gradient: true } },
|
|
11
|
-
{ type: 'prose', description: 'Body text
|
|
12
|
-
{ type: 'image', description: 'A responsive image with an optional caption. Corners follow the site by default.', block: { type: 'image', src: '/placeholder.svg', alt: 'Placeholder', caption: 'An image with a caption.' } },
|
|
13
|
-
{ type: 'image', description: 'Canonical framing options: soft corners + drop shadow + a fixed 16/9 ratio (image is cover-fit).', block: { type: 'image', src: '/showcase-hero.svg', alt: 'Abstract', radius: 'soft', shadow: true, ratio: '16/9', caption: 'radius: soft · shadow · ratio 16/9' } },
|
|
14
|
-
{ type: 'image', description: 'Square ratio with an outline stroke and sharp corners — same block, different canonical options.', block: { type: 'image', src: '/showcase-hero.svg', alt: 'Abstract', radius: 'none', border: true, ratio: 'square', caption: 'radius: none · stroke · square' } },
|
|
15
|
-
{ type: 'button', description: 'Primary action — the default. Variant is a role, not decoration; corner shape follows the global radius knob.', block: { type: 'button', label: 'Primary', href: '#' } },
|
|
16
|
-
{ type: 'button', description: 'Secondary action — a quieter outline button.', block: { type: 'button', label: 'Secondary', href: '#', variant: 'secondary' } },
|
|
17
|
-
{ type: 'button', description: 'Filled CTA — the brand-accent call to action.', block: { type: 'button', label: 'Get started', href: '#', variant: 'cta' } },
|
|
18
|
-
{ type: 'button', description: 'Ghost — a quiet, borderless text button for tertiary actions.', block: { type: 'button', label: 'Learn more', href: '#', variant: 'ghost' } },
|
|
19
|
-
{ type: 'quote', description: 'A pull quote with an optional citation.', block: { type: 'quote', text: 'A memorable quotation, set apart.', cite: 'Someone' } },
|
|
20
|
-
{ type: 'divider', description: 'A horizontal rule between sections.', block: { type: 'divider' } },
|
|
21
|
-
{ type: 'spacer', description: 'Vertical space (sm / md / lg) — invisible by design.', block: { type: 'spacer', size: 'lg' } },
|
|
22
|
-
{ type: 'section', description: 'A full-width band (background + width) holding child blocks.', block: { type: 'section', background: 'muted', width: 'normal', blocks: [{ type: 'heading', text: 'Inside a section', level: 3 }, { type: 'prose', text: 'Sections hold child blocks.' }] } },
|
|
23
|
-
{ type: 'grid', description: 'A responsive N-column grid of child blocks.', block: { type: 'grid', columns: 3, blocks: [{ type: 'prose', text: 'Column one' }, { type: 'prose', text: 'Column two' }, { type: 'prose', text: 'Column three' }] } },
|
|
24
|
-
{ type: 'stack', description: 'Stacks child blocks vertically with a consistent gap.', block: { type: 'stack', gap: 'sm', blocks: [{ type: 'prose', text: 'Stacked one' }, { type: 'prose', text: 'Stacked two' }] } },
|
|
25
|
-
{ type: 'badge', description: 'A small pill label (accent or neutral tone).', block: { type: 'badge', text: 'New', tone: 'accent' } },
|
|
26
|
-
{ type: 'stat', description: 'A headline number with a label.', block: { type: 'stat', value: '250+', label: 'families served' } },
|
|
27
|
-
{ type: 'list', description: 'A bulleted, numbered, or checked list of lines.', block: { type: 'list', style: 'check', items: ['Everything shares one design language', 'Your brand, applied consistently', 'You own the output'] } },
|
|
28
|
-
];
|
|
1
|
+
/** One catalogue entry: a block type, a short description, and a live example. */
|
|
2
|
+
export interface GalleryEntry {
|
|
3
|
+
type: string;
|
|
4
|
+
description?: string;
|
|
5
|
+
block: Record<string, unknown>;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export const primitives: GalleryEntry[] = [
|
|
9
|
+
{ type: 'heading', description: 'A section title, levels 1–6, optionally aligned.', block: { type: 'heading', text: 'A heading', level: 2 } },
|
|
10
|
+
{ type: 'heading', description: 'Optional gradient text — brand → derived accent-2 (falls back to solid ink where unsupported).', block: { type: 'heading', text: 'A gradient heading', level: 2, gradient: true } },
|
|
11
|
+
{ type: 'prose', description: 'Body text: blank lines start new paragraphs, `- ` / `1. ` lines make lists, and inline links (incl. phone and email), bold and italic render safely.', block: { type: 'prose', text: 'A paragraph of prose with **bold**, *italic* and a [link to the recipes](/recipes).\n\nFor a quick answer, [phone the office](tel:01234567890).\n\n- Lists from simple lines\n- With a [link](/primitives) inside' } },
|
|
12
|
+
{ type: 'image', description: 'A responsive image with an optional caption. Corners follow the site by default.', block: { type: 'image', src: '/placeholder.svg', alt: 'Placeholder', caption: 'An image with a caption.' } },
|
|
13
|
+
{ type: 'image', description: 'Canonical framing options: soft corners + drop shadow + a fixed 16/9 ratio (image is cover-fit).', block: { type: 'image', src: '/showcase-hero.svg', alt: 'Abstract', radius: 'soft', shadow: true, ratio: '16/9', caption: 'radius: soft · shadow · ratio 16/9' } },
|
|
14
|
+
{ type: 'image', description: 'Square ratio with an outline stroke and sharp corners — same block, different canonical options.', block: { type: 'image', src: '/showcase-hero.svg', alt: 'Abstract', radius: 'none', border: true, ratio: 'square', caption: 'radius: none · stroke · square' } },
|
|
15
|
+
{ type: 'button', description: 'Primary action — the default. Variant is a role, not decoration; corner shape follows the global radius knob.', block: { type: 'button', label: 'Primary', href: '#' } },
|
|
16
|
+
{ type: 'button', description: 'Secondary action — a quieter outline button.', block: { type: 'button', label: 'Secondary', href: '#', variant: 'secondary' } },
|
|
17
|
+
{ type: 'button', description: 'Filled CTA — the brand-accent call to action.', block: { type: 'button', label: 'Get started', href: '#', variant: 'cta' } },
|
|
18
|
+
{ type: 'button', description: 'Ghost — a quiet, borderless text button for tertiary actions.', block: { type: 'button', label: 'Learn more', href: '#', variant: 'ghost' } },
|
|
19
|
+
{ type: 'quote', description: 'A pull quote with an optional citation.', block: { type: 'quote', text: 'A memorable quotation, set apart.', cite: 'Someone' } },
|
|
20
|
+
{ type: 'divider', description: 'A horizontal rule between sections.', block: { type: 'divider' } },
|
|
21
|
+
{ type: 'spacer', description: 'Vertical space (sm / md / lg) — invisible by design.', block: { type: 'spacer', size: 'lg' } },
|
|
22
|
+
{ type: 'section', description: 'A full-width band (background + width) holding child blocks.', block: { type: 'section', background: 'muted', width: 'normal', blocks: [{ type: 'heading', text: 'Inside a section', level: 3 }, { type: 'prose', text: 'Sections hold child blocks.' }] } },
|
|
23
|
+
{ type: 'grid', description: 'A responsive N-column grid of child blocks.', block: { type: 'grid', columns: 3, blocks: [{ type: 'prose', text: 'Column one' }, { type: 'prose', text: 'Column two' }, { type: 'prose', text: 'Column three' }] } },
|
|
24
|
+
{ type: 'stack', description: 'Stacks child blocks vertically with a consistent gap.', block: { type: 'stack', gap: 'sm', blocks: [{ type: 'prose', text: 'Stacked one' }, { type: 'prose', text: 'Stacked two' }] } },
|
|
25
|
+
{ type: 'badge', description: 'A small pill label (accent or neutral tone).', block: { type: 'badge', text: 'New', tone: 'accent' } },
|
|
26
|
+
{ type: 'stat', description: 'A headline number with a label.', block: { type: 'stat', value: '250+', label: 'families served' } },
|
|
27
|
+
{ type: 'list', description: 'A bulleted, numbered, or checked list of lines.', block: { type: 'list', style: 'check', items: ['Everything shares one design language', 'Your brand, applied consistently', 'You own the output'] } },
|
|
28
|
+
];
|
package/examples/recipes.ts
CHANGED
|
@@ -1,32 +1,37 @@
|
|
|
1
|
-
import type { GalleryEntry } from './primitives';
|
|
2
|
-
|
|
3
|
-
export const recipes: GalleryEntry[] = [
|
|
4
|
-
{ type: 'hero', description: 'Page-opening band — centred preset.', block: { type: 'hero', eyebrow: 'Recipe', title: 'Hero (centered)', subtitle: 'A curated opening band composing Heading + Button.', cta: { label: 'Primary action', href: '#' }, layout: 'centered' } },
|
|
5
|
-
{ type: 'hero', description: 'Page-opening band — split preset (text beside image).', block: { type: 'hero', title: 'Hero (split)', subtitle: 'Text beside an image.', cta: { label: 'Primary action', href: '#' }, image: { src: '/placeholder.svg', alt: 'Placeholder' }, layout: 'split' } },
|
|
6
|
-
{ type: '
|
|
7
|
-
{ type: '
|
|
8
|
-
{ type: '
|
|
9
|
-
{ type: '
|
|
10
|
-
{ type: '
|
|
11
|
-
{ type: '
|
|
12
|
-
{ type: '
|
|
13
|
-
{ type: '
|
|
14
|
-
{ type: '
|
|
15
|
-
{ type: 'tiles', description: '
|
|
16
|
-
{ type: '
|
|
17
|
-
{ type: '
|
|
18
|
-
{ type: '
|
|
19
|
-
{ type: '
|
|
20
|
-
{ type: '
|
|
21
|
-
{ type: '
|
|
22
|
-
{ type: '
|
|
23
|
-
{ type: '
|
|
24
|
-
{ type: '
|
|
25
|
-
{ type: '
|
|
26
|
-
{ type: '
|
|
27
|
-
{ type: '
|
|
28
|
-
{ type: '
|
|
29
|
-
{ type: '
|
|
30
|
-
{ type: '
|
|
31
|
-
{ type: '
|
|
32
|
-
]
|
|
1
|
+
import type { GalleryEntry } from './primitives';
|
|
2
|
+
|
|
3
|
+
export const recipes: GalleryEntry[] = [
|
|
4
|
+
{ type: 'hero', description: 'Page-opening band — centred preset.', block: { type: 'hero', eyebrow: 'Recipe', title: 'Hero (centered)', subtitle: 'A curated opening band composing Heading + Button.', cta: { label: 'Primary action', href: '#' }, layout: 'centered' } },
|
|
5
|
+
{ type: 'hero', description: 'Page-opening band — split preset (text beside image).', block: { type: 'hero', title: 'Hero (split)', subtitle: 'Text beside an image.', cta: { label: 'Primary action', href: '#' }, image: { src: '/placeholder.svg', alt: 'Placeholder' }, layout: 'split' } },
|
|
6
|
+
{ type: 'hero', description: 'Media preset with a slow crossfade through several images — pauses off-screen, has a pause button, and stays on the first image under reduced motion. A single image is a still background; a `video` loops instead (and `size: "screen"` fills the first screen, edge to edge).', block: { type: 'hero', eyebrow: 'Welcome', title: 'Hero (media, rotating images)', subtitle: 'Text over a background that changes slowly.', cta: { label: 'Primary action', href: '#' }, layout: 'media', size: 'tall', media: { images: ['/showcase-hero.svg', '/placeholder.svg'], overlay: 45 } } },
|
|
7
|
+
{ type: 'hero', description: 'Media preset looping a YouTube (or Vimeo) video behind the text: paste the ordinary link as `video`. Until the visitor accepts cookies it shows the `poster` still and links to the video; then the muted, chromeless loop fades in, with a pause button. Reduced motion keeps the still.', block: { type: 'hero', title: 'Hero (media, YouTube background)', subtitle: 'A video opener without hosting the file yourself.', layout: 'media', size: 'tall', media: { video: 'https://youtu.be/aqz-KE-bpKQ', poster: '/showcase-hero.svg', overlay: 50 } } },
|
|
8
|
+
{ type: 'featureCards', description: 'An optional header over a responsive grid of icon + title + text cards.', block: { type: 'featureCards', title: 'Feature cards', columns: 3, cards: [{ icon: 'book-open', title: 'First feature', text: 'A short description.' }, { icon: 'shield', title: 'Second feature', text: 'A short description.' }, { icon: 'sparkle', title: 'Third feature', text: 'A short description.' }] } },
|
|
9
|
+
{ type: 'cta', description: 'A centred call-to-action band (heading + text + button).', block: { type: 'cta', title: 'Ready to start?', text: 'A closing nudge with a single, clear action.', cta: { label: 'Get in touch', href: '#' }, background: 'accent' } },
|
|
10
|
+
{ type: 'faq', description: 'Expandable question / answer list — native, accessible, no JavaScript.', block: { type: 'faq', title: 'Frequently asked', items: [{ question: 'How are pages built?', answer: 'From a curated set of blocks, composed into your pages and verified before they go live — see the [architecture notes](https://example.com/architecture).' }, { question: 'Can I use my own brand?', answer: 'Yes — colours and fonts are your config; the core is brand-agnostic.' }] } },
|
|
11
|
+
{ type: 'gallery', description: 'A responsive grid of images with optional captions.', block: { type: 'gallery', columns: 3, images: [{ src: '/placeholder.svg', alt: 'One', caption: 'One' }, { src: '/placeholder.svg', alt: 'Two', caption: 'Two' }, { src: '/placeholder.svg', alt: 'Three', caption: 'Three' }] } },
|
|
12
|
+
{ type: 'testimonial', description: 'A centred quotation with author, role and an optional avatar.', block: { type: 'testimonial', quote: 'The showcase made the whole model click in a minute.', author: 'A. Client', role: 'Parish administrator' } },
|
|
13
|
+
{ type: 'contactForm', description: 'An accessible contact form in a centred column — labelled fields, a submit and an optional note. Fields are generic, so it also serves enquiry / sign-up forms.', block: { type: 'contactForm', title: 'Send us a message', intro: 'We usually reply within a day.', note: 'Demo form — the showcase has no backend; a real client points its action at their own endpoint.' } },
|
|
14
|
+
{ type: 'accordion', description: 'Generic expand/collapse rows (title + body) — native, accessible, no JavaScript. FAQ’s domain-free sibling.', block: { type: 'accordion', title: 'How it works', items: [{ heading: 'Composable', body: 'Pages are ordered blocks stored as JSON.' }, { heading: 'Consistent', body: 'Recipes lock a good layout so quality can’t drift.' }] } },
|
|
15
|
+
{ type: 'tiles', description: 'A card grid with five looks — here “elevated” (soft shadow, lifts on hover) with the optional pointer-following spotlight. Radius / stroke / elevation come from the global surface tokens.', block: { type: 'tiles', variant: 'elevated', columns: 3, spotlight: true, tiles: [{ icon: 'sparkle', eyebrow: 'Level 1', title: 'Primitives', text: 'The raw ingredients.', href: '#' }, { icon: 'book-open', eyebrow: 'Level 2', title: 'Recipes', text: 'Curated compositions.', href: '#' }, { icon: 'shield', eyebrow: 'Level 3', title: 'Templates', text: 'Whole pages.', href: '#' }] } },
|
|
16
|
+
{ type: 'tiles', description: 'The “gradient” variant — brand → a derived accent-2 (an OKLCH hue-shift of your one brand colour), light text.', block: { type: 'tiles', variant: 'gradient', columns: 3, tiles: [{ icon: 'flame', title: 'Vibrant', text: 'One brand colour, a harmonious gradient.' }, { icon: 'sparkle', title: 'Derived', text: 'Accent-2 is computed, not configured.' }, { icon: 'heart', title: 'On-brand', text: 'Always harmonises with your hue.' }] } },
|
|
17
|
+
{ type: 'tiles', description: 'The “image” variant — a full-bleed photo with a legibility scrim and light text; the whole tile links.', block: { type: 'tiles', variant: 'image', columns: 3, tiles: [{ eyebrow: 'Feature', title: 'Photo tile', text: 'Text stays legible over any image.', href: '#', image: { src: '/placeholder.svg', alt: 'Placeholder' } }, { title: 'Second', text: 'Cover-fit background.', href: '#', image: { src: '/placeholder.svg', alt: 'Placeholder' } }, { title: 'Third', text: 'With a gradient scrim.', href: '#', image: { src: '/placeholder.svg', alt: 'Placeholder' } }] } },
|
|
18
|
+
{ type: 'tiles', description: 'Picture tiles — the “image” variant with no words: the picture alone is the tile (and the button, when it links), shown whole whatever its shape (a poster, a wide logo, a badge), named by its alt text. Mix with worded tiles; `linkLabel` sets the cue.', block: { type: 'tiles', variant: 'image', columns: 3, tiles: [{ href: '#', image: { src: '/placeholder.svg', alt: 'Summer fair poster — details' } }, { href: '#', image: { src: '/logos/acme.svg', alt: 'Acme — our partner' } }, { title: 'Newsletters', text: 'Every issue, newest first.', href: '#', linkLabel: 'Read them', image: { src: '/placeholder.svg', alt: '' } }] } },
|
|
19
|
+
{ type: 'banner', description: 'A full-bleed feature band — a fixed/parallax background image with a scrim and a floating “shaded glass” card. Pure-CSS parallax; disabled on touch and under reduced-motion.', block: { type: 'banner', image: '/showcase-hero.svg', eyebrow: 'Featured', title: 'A banner that moves', text: 'The image is pinned to the viewport, so it drifts under this card as you scroll.', cta: { label: 'Take a look', href: '#' }, align: 'start' } },
|
|
20
|
+
{ type: 'stats', description: 'A band of headline numbers (value + label) over an optional heading — social proof at a glance.', block: { type: 'stats', title: 'By the numbers', background: 'muted', columns: 3, items: [{ value: '250+', label: 'projects shipped' }, { value: '99.9%', label: 'uptime', description: 'rolling 12 months' }, { value: '<1 day', label: 'average reply' }] } },
|
|
21
|
+
{ type: 'steps', description: 'A numbered process — connected cards (horizontal) or a stacked timeline (vertical).', block: { type: 'steps', title: 'How it works', layout: 'horizontal', items: [{ icon: 'chat', title: 'Tell us the problem', text: 'A straight conversation about the work costing you time.' }, { icon: 'sparkle', title: 'We fit it to you', text: 'The platform, shaped around how you actually work.' }, { icon: 'shield', title: 'You run it', text: 'Yours to use; we keep it running.' }] } },
|
|
22
|
+
{ type: 'pricing', description: 'Plan cards with price, feature list and CTA; one plan can be “featured”. Prices shown are placeholder sample data, not real offers.', block: { type: 'pricing', title: 'Pricing (layout example)', intro: 'Sample plans to show the layout — placeholder figures, not real prices.', plans: [{ name: 'Tier one', price: '$XX', period: '/mo', description: 'Example plan.', features: ['Example feature', 'Example feature', 'Example feature'], cta: { label: 'Example CTA', href: '#' } }, { name: 'Tier two', price: '$XX', period: '/mo', description: 'Example plan (featured).', features: ['Everything in tier one', 'Example feature', 'Example feature'], cta: { label: 'Example CTA', href: '#' }, featured: true }, { name: 'Tier three', price: 'Custom', description: 'Example plan.', features: ['Everything in tier two', 'Example feature', 'Example feature'], cta: { label: 'Example CTA', href: '#' } }] } },
|
|
23
|
+
{ type: 'logos', description: 'A “trusted by” wall — greyscale and dimmed, coming to colour on hover. (Sample logos — fictional companies.)', block: { type: 'logos', title: 'Trusted by teams like yours', items: [{ src: '/logos/acme.svg', alt: 'Acme' }, { src: '/logos/nimbus.svg', alt: 'Nimbus' }, { src: '/logos/vertex.svg', alt: 'Vertex' }, { src: '/logos/harbor.svg', alt: 'Harbor' }, { src: '/logos/quanta.svg', alt: 'Quanta' }] } },
|
|
24
|
+
{ type: 'tabs', description: 'Labelled panels, one open at a time — progressive (first panel shows with no JS), with ARIA + arrow-key nav.', block: { type: 'tabs', title: 'One system, many angles', items: [{ label: 'Design', heading: 'Design, once', text: 'Set colour, type and style once; every page follows.' }, { label: 'Build', heading: 'Built to last', text: 'A curated system, not a pile of one-offs.' }, { label: 'Own', heading: 'Yours to keep', text: 'Static output and plain content in your own repo.' }] } },
|
|
25
|
+
{ type: 'announcement', description: 'A thin full-width bar (message + optional link), dismissible — the dismissal is remembered per id.', block: { type: 'announcement', text: 'New: five section recipes just landed.', link: { label: 'See the recipes', href: '#' }, tone: 'accent', id: 'gallery-demo' } },
|
|
26
|
+
{ type: 'bento', description: 'An asymmetric grid — cards pick a size (sm / wide / tall / lg) and a look (surface / filled / gradient / image). Dense packing, the “living moodboard” layout.', block: { type: 'bento', items: [{ size: 'lg', variant: 'gradient', icon: 'sparkle', eyebrow: 'Flagship', title: 'The big idea', text: 'A 2×2 tile anchors the grid.' }, { size: 'sm', variant: 'surface', icon: 'shield', title: 'Trust' }, { size: 'sm', variant: 'filled', icon: 'heart', title: 'Care' }, { size: 'wide', variant: 'image', eyebrow: 'In use', title: 'A wide photo tile', image: { src: '/showcase-hero.svg', alt: 'Abstract' } }, { size: 'sm', variant: 'surface', icon: 'checklist', title: 'Simple' }, { size: 'sm', variant: 'surface', icon: 'book-open', title: 'Clear' }] } },
|
|
27
|
+
{ type: 'marquee', description: 'An infinite feature strip — a pure-CSS scrolling loop that pauses on hover and wraps statically under reduced-motion. Hover it to pause.', block: { type: 'marquee', speed: 'normal', items: ['Design once', 'Apply everywhere', 'Unmistakably yours', 'You own it', 'One system'] } },
|
|
28
|
+
{ type: 'latestPosts', description: 'Surfaces the posts capability inside a page — the most-recent posts as full image cards (`grid`) or a compact miniature index (`list`: thumbnail + category + date per row, staying short however many posts), with a “view all” link. Queried from the site’s own posts at build time, so a homepage “Latest news” needs no custom code.', block: { type: 'latestPosts', title: 'Latest from the showcase', limit: 4, layout: 'list' } },
|
|
29
|
+
{ type: 'mapEmbed', description: 'A consent-gated map (e.g. Google Maps) — the sibling of the calendar embed. Nothing loads from the provider until the visitor clicks “Load map”, so no third-party request fires on page load; consent is remembered per visitor.', block: { type: 'mapEmbed', title: 'Find us', intro: 'Where to find the showcase.', embedUrl: 'https://www.google.com/maps/embed?pb=!demo', publicUrl: 'https://maps.google.com/', provider: 'Google Maps' } },
|
|
30
|
+
{ type: 'table', description: 'A simple data table (header row + body rows) under an optional heading — for structured data the other blocks can’t carry cleanly (fees, opening hours, a cookies list). Striped, and scrolls horizontally on narrow screens.', block: { type: 'table', title: 'Hall hire', intro: 'Sample rates.', columns: ['Item', 'Rate', 'Notes'], rows: [['Hall (per hour)', '£20', 'Deposit confirms the booking'], ['Insurance', '£1.50/hr', 'Waived with your own public liability cover'], ['Cleaning deposit', '£50', 'Refundable']] } },
|
|
31
|
+
{ type: 'notice', description: 'An inline highlighted info box (icon + title + body + optional link) that sets a passage apart from the surrounding prose. Two tones (accent / neutral); the in-flow sibling of the corner sticky callout.', block: { type: 'notice', tone: 'accent', icon: 'shield', title: 'Reporting a concern', text: 'The parish has a Safeguarding Representative you can contact through the Parish Office.', link: { label: 'Safeguarding policy', href: '#' } } },
|
|
32
|
+
{ type: 'mediaCards', description: 'Rich content cards with the image as a HEADER on top (cover-cropped) + an icon badge, title, meta pill, description and CTA — the image and copy both stay fully legible (unlike the tiles image variant, which puts the image behind the text). For groups, team, services or events.', block: { type: 'mediaCards', title: 'Get involved', intro: 'Cards whose photo and copy both need to read clearly.', columns: 3, cards: [{ image: { src: '/placeholder.svg', alt: '' }, icon: 'heart', title: 'Prayer Group', meta: 'Sundays 10:45 · Parish hall', text: 'Praying together before the Sunday service — newcomers always welcome.', href: '#', linkLabel: 'Find out more' }, { image: { src: '/placeholder.svg', alt: '' }, icon: 'people', title: 'Community League', meta: 'First Wednesday · 7pm', text: 'Faith, friendship, service and mission.', href: '#' }, { image: { src: '/placeholder.svg', alt: '' }, icon: 'globe', title: 'Partner Community', meta: 'Your area', text: 'A neighbouring community we work closely with.', href: '#', linkLabel: 'Visit their site' }] } },
|
|
33
|
+
{ type: 'contactCard', description: 'A branded "get in touch" band — a theme-adaptive panel (a section surface with a soft brand-gold corner wash) carrying a display-type title + subtitle on the left, contact channels as tappable tiles on the right, and a closing note across the bottom. Composes in both light and dark. The polished alternative to a plain notice box.', block: { type: 'contactCard', eyebrow: 'Parish office', title: 'Get in touch', subtitle: 'Parish Priest: Fr John Doe', note: 'For a quick reply, phone the office directly — email is best for non-urgent matters. See our [Safeguarding policy](/safeguarding).', items: [{ icon: 'map-pin', value: '1 Example Street\nPlaceholdertown EX4 3PL' }, { icon: 'phone', value: '01234 567890', href: 'tel:01234567890' }, { icon: 'email', value: 'office@st-placeholder.example', href: 'mailto:office@st-placeholder.example' }, { icon: 'link', value: 'Follow us on Facebook', href: 'https://facebook.com/' }] } },
|
|
34
|
+
{ type: 'locations', description: 'A "where to find us" grid — one or more places, each a neat tile with a consent-gated map on top and its name / address / directions below. Maps load once the visitor accepts the site cookie banner (shared consent, no per-tile gate). Replaces stacking bare map embeds.', block: { type: 'locations', title: 'Where to find us', columns: 2, items: [{ name: "St Placeholder's", address: '1 Example Street\nPlaceholdertown EX4 3PL', embedUrl: 'https://www.google.com/maps/embed?pb=!demo1', directionsUrl: 'https://maps.google.com/' }, { name: "St Sample's", address: '2 Sample Road\nPlaceholdertown', embedUrl: 'https://www.google.com/maps/embed?pb=!demo2', directionsUrl: 'https://maps.google.com/' }] } },
|
|
35
|
+
{ type: 'video', description: 'A consent-gated video — paste an ordinary YouTube (incl. a channel’s live page) or Vimeo link; it plays through the privacy-friendly embed only once the visitor agrees, with a “watch on YouTube” alternative. Fixed ratio, never overflows.', block: { type: 'video', title: 'Watch', caption: 'Sample video link.', url: 'https://www.youtube.com/watch?v=aqz-KE-bpKQ', ratio: '16/9' } },
|
|
36
|
+
{ type: 'documents', description: 'A list of files — newsletters, minutes, policies, forms — newest first, optionally grouped by year: type badge, title, date and note per row. Opens in the browser’s own viewer rather than an in-page one (better on phones).', block: { type: 'documents', title: 'Newsletters', intro: 'Every issue, newest first.', groupByYear: true, items: [{ title: 'Newsletter — Advent', file: '/files/sample-advent.pdf', date: '2026-11-30' }, { title: 'Newsletter — Harvest', file: '/files/sample-harvest.pdf', date: '2026-10-04', note: 'Includes the harvest appeal' }, { title: 'Newsletter — Christmas', file: '/files/sample-christmas.pdf', date: '2025-12-21' }, { title: 'Hall hire form', file: '/files/sample-hire-form.docx' }] } },
|
|
37
|
+
];
|
package/lib/documents.ts
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `documents` block's list logic: newest first (dated items by date, undated ones
|
|
3
|
+
* after, in the order given), an optional grouping by year, a file-type label from the
|
|
4
|
+
* extension, and a readable date. Pure — the component only renders the result.
|
|
5
|
+
*/
|
|
6
|
+
export interface DocumentItem {
|
|
7
|
+
title: string;
|
|
8
|
+
file: string;
|
|
9
|
+
date?: string;
|
|
10
|
+
note?: string;
|
|
11
|
+
}
|
|
12
|
+
export interface DocumentRow extends DocumentItem {
|
|
13
|
+
kind: string;
|
|
14
|
+
dateLabel?: string;
|
|
15
|
+
}
|
|
16
|
+
export interface DocumentGroup {
|
|
17
|
+
year: string | null;
|
|
18
|
+
items: DocumentRow[];
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const KINDS: Record<string, string> = {
|
|
22
|
+
pdf: 'PDF', doc: 'Word', docx: 'Word', xls: 'Excel', xlsx: 'Excel', csv: 'CSV',
|
|
23
|
+
ppt: 'PowerPoint', pptx: 'PowerPoint', odt: 'Document', rtf: 'Document', txt: 'Text',
|
|
24
|
+
jpg: 'Image', jpeg: 'Image', png: 'Image', zip: 'ZIP',
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export function fileKind(file: string): string {
|
|
28
|
+
const ext = file.split(/[?#]/)[0].split('.').pop()?.toLowerCase() ?? '';
|
|
29
|
+
return KINDS[ext] ?? 'File';
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** A valid ISO-ish date (YYYY-MM-DD…) → a Date, else null. */
|
|
33
|
+
function parseDate(d: string | undefined): Date | null {
|
|
34
|
+
if (!d || !/^\d{4}-\d{2}-\d{2}/.test(d)) return null;
|
|
35
|
+
const t = new Date(d.slice(0, 10) + 'T00:00:00Z');
|
|
36
|
+
return Number.isNaN(t.getTime()) ? null : t;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function documentGroups(items: DocumentItem[], groupByYear = false): DocumentGroup[] {
|
|
40
|
+
const rows = items.map((item, i) => {
|
|
41
|
+
const d = parseDate(item.date);
|
|
42
|
+
return {
|
|
43
|
+
row: {
|
|
44
|
+
...item,
|
|
45
|
+
kind: fileKind(item.file),
|
|
46
|
+
dateLabel: d ? d.toLocaleDateString('en-GB', { day: 'numeric', month: 'long', year: 'numeric', timeZone: 'UTC' }) : undefined,
|
|
47
|
+
} as DocumentRow,
|
|
48
|
+
time: d?.getTime() ?? null,
|
|
49
|
+
year: d ? String(d.getUTCFullYear()) : null,
|
|
50
|
+
i,
|
|
51
|
+
};
|
|
52
|
+
});
|
|
53
|
+
rows.sort((a, b) => (a.time !== null && b.time !== null ? b.time - a.time : a.time !== null ? -1 : b.time !== null ? 1 : a.i - b.i));
|
|
54
|
+
if (!groupByYear) return [{ year: null, items: rows.map((r) => r.row) }];
|
|
55
|
+
const groups: DocumentGroup[] = [];
|
|
56
|
+
for (const r of rows) {
|
|
57
|
+
const last = groups[groups.length - 1];
|
|
58
|
+
if (last && last.year === r.year) last.items.push(r.row);
|
|
59
|
+
else groups.push({ year: r.year, items: [r.row] });
|
|
60
|
+
}
|
|
61
|
+
return groups;
|
|
62
|
+
}
|