ferst-core 0.6.2 → 0.7.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/bin/ferst-update.mjs +73 -2
- package/components/BlockRenderer.astro +107 -103
- package/components/blocks/Prose.astro +56 -39
- package/components/recipes/Documents.astro +146 -0
- package/components/recipes/Hero.astro +383 -191
- package/components/recipes/Tiles.astro +23 -4
- package/components/recipes/Video.astro +157 -0
- package/content/blocks.ts +773 -719
- package/content/cookies.ts +112 -103
- package/examples/primitives.ts +28 -28
- package/examples/recipes.ts +4 -0
- package/lib/documents.ts +62 -0
- package/lib/inline-markdown.ts +59 -52
- package/lib/prose.ts +29 -0
- package/lib/video.ts +56 -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
|
@@ -3,6 +3,7 @@ import type { GalleryEntry } from './primitives';
|
|
|
3
3
|
export const recipes: GalleryEntry[] = [
|
|
4
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
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 } } },
|
|
6
7
|
{ 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.' }] } },
|
|
7
8
|
{ 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' } },
|
|
8
9
|
{ 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.' }] } },
|
|
@@ -13,6 +14,7 @@ export const recipes: GalleryEntry[] = [
|
|
|
13
14
|
{ 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: '#' }] } },
|
|
14
15
|
{ 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.' }] } },
|
|
15
16
|
{ 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' } }] } },
|
|
17
|
+
{ type: 'tiles', description: 'Picture tiles — the “image” variant with no words: the picture alone is the tile (and the button, when it links), 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: '/placeholder.svg', alt: 'Partner charity — visit their site' } }, { title: 'Newsletters', text: 'Every issue, newest first.', href: '#', linkLabel: 'Read them', image: { src: '/placeholder.svg', alt: '' } }] } },
|
|
16
18
|
{ 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' } },
|
|
17
19
|
{ 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' }] } },
|
|
18
20
|
{ 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.' }] } },
|
|
@@ -29,4 +31,6 @@ export const recipes: GalleryEntry[] = [
|
|
|
29
31
|
{ 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' }] } },
|
|
30
32
|
{ 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.', items: [{ 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/' }] } },
|
|
31
33
|
{ 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/' }] } },
|
|
34
|
+
{ 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' } },
|
|
35
|
+
{ 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' }] } },
|
|
32
36
|
];
|
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
|
+
}
|
package/lib/inline-markdown.ts
CHANGED
|
@@ -1,52 +1,59 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* inlineMarkdown — render a *tiny*, safe subset of markdown from trusted editor/CMS
|
|
3
|
-
* text: links `[label](href)`, bold `**text**`,
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
'
|
|
15
|
-
'
|
|
16
|
-
'
|
|
17
|
-
"'
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* inlineMarkdown — render a *tiny*, safe subset of markdown from trusted editor/CMS
|
|
3
|
+
* text: links `[label](href)`, bold `**text**`, italic `*text*` / `_text_`, and single
|
|
4
|
+
* newlines → `<br>`.
|
|
5
|
+
* Everything is HTML-escaped first, and only safe href schemes (http(s), root-
|
|
6
|
+
* relative, mailto, tel, in-page #anchors) become links — an unsafe href renders
|
|
7
|
+
* as literal text. The result is safe to drop into `set:html` for trusted content.
|
|
8
|
+
*
|
|
9
|
+
* This exists so recipes that show a line or two of editor copy (FAQ answers,
|
|
10
|
+
* notices) can carry a link without a full markdown pipeline — and without the
|
|
11
|
+
* `[text](url)` showing through as literal characters.
|
|
12
|
+
*/
|
|
13
|
+
const ESCAPE: Record<string, string> = {
|
|
14
|
+
'&': '&',
|
|
15
|
+
'<': '<',
|
|
16
|
+
'>': '>',
|
|
17
|
+
'"': '"',
|
|
18
|
+
"'": ''',
|
|
19
|
+
};
|
|
20
|
+
function escapeHtml(s: string): string {
|
|
21
|
+
return s.replace(/[&<>"']/g, (c) => ESCAPE[c]);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Only these href schemes are allowed to become real links. */
|
|
25
|
+
const SAFE_HREF = /^(?:https?:\/\/|\/|mailto:|tel:|#)/i;
|
|
26
|
+
|
|
27
|
+
const LINK_RE = /\[([^\]]+)\]\(([^)\s]+)\)/g;
|
|
28
|
+
|
|
29
|
+
/** Bold, italic + single-newline breaks, applied to already-escaped text. Italic
|
|
30
|
+
* `*x*` / `_x_` must hug its text, and `_` only counts at word edges, so
|
|
31
|
+
* snake_case and a lone asterisk stay literal. */
|
|
32
|
+
function inlineFormat(escaped: string): string {
|
|
33
|
+
return escaped
|
|
34
|
+
.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
|
|
35
|
+
.replace(/(^|[^*\w])\*(?![\s*])([^*]*?[^\s*])\*(?!\*)/g, '$1<em>$2</em>')
|
|
36
|
+
.replace(/(^|[^\w])_(?![\s_])([^_]*?[^\s_])_(?!\w)/g, '$1<em>$2</em>')
|
|
37
|
+
.replace(/\n/g, '<br>');
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function inlineMarkdown(text: string | undefined | null): string {
|
|
41
|
+
if (!text) return '';
|
|
42
|
+
let out = '';
|
|
43
|
+
let last = 0;
|
|
44
|
+
let m: RegExpExecArray | null;
|
|
45
|
+
LINK_RE.lastIndex = 0;
|
|
46
|
+
while ((m = LINK_RE.exec(text))) {
|
|
47
|
+
out += inlineFormat(escapeHtml(text.slice(last, m.index)));
|
|
48
|
+
const label = inlineFormat(escapeHtml(m[1]));
|
|
49
|
+
const href = m[2];
|
|
50
|
+
if (SAFE_HREF.test(href)) {
|
|
51
|
+
out += `<a href="${escapeHtml(href)}">${label}</a>`;
|
|
52
|
+
} else {
|
|
53
|
+
out += inlineFormat(escapeHtml(m[0])); // unsafe scheme → keep literal
|
|
54
|
+
}
|
|
55
|
+
last = m.index + m[0].length;
|
|
56
|
+
}
|
|
57
|
+
out += inlineFormat(escapeHtml(text.slice(last)));
|
|
58
|
+
return out;
|
|
59
|
+
}
|
package/lib/prose.ts
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Prose → paragraphs and simple lists, for the `prose` block. Blank lines separate
|
|
3
|
+
* paragraphs; a paragraph whose every line starts `- ` / `* ` becomes a bulleted list,
|
|
4
|
+
* and one whose every line starts `1. ` / `1) ` a numbered list. Inline formatting
|
|
5
|
+
* (links incl. tel:/mailto:, bold, italic) comes from inlineMarkdown, which escapes
|
|
6
|
+
* everything first — so the output is safe for `set:html`.
|
|
7
|
+
*/
|
|
8
|
+
import { inlineMarkdown } from './inline-markdown';
|
|
9
|
+
|
|
10
|
+
export type ProsePart =
|
|
11
|
+
| { kind: 'p'; html: string }
|
|
12
|
+
| { kind: 'ul' | 'ol'; items: string[] };
|
|
13
|
+
|
|
14
|
+
const BULLET = /^\s*[-*•]\s+/;
|
|
15
|
+
const NUMBER = /^\s*\d+[.)]\s+/;
|
|
16
|
+
|
|
17
|
+
export function proseParts(text: string | undefined | null): ProsePart[] {
|
|
18
|
+
if (!text) return [];
|
|
19
|
+
return text
|
|
20
|
+
.split(/\n\s*\n/)
|
|
21
|
+
.map((p) => p.trim())
|
|
22
|
+
.filter(Boolean)
|
|
23
|
+
.map((p): ProsePart => {
|
|
24
|
+
const lines = p.split('\n').map((l) => l.trim()).filter(Boolean);
|
|
25
|
+
if (lines.every((l) => BULLET.test(l))) return { kind: 'ul', items: lines.map((l) => inlineMarkdown(l.replace(BULLET, ''))) };
|
|
26
|
+
if (lines.length > 1 && lines.every((l) => NUMBER.test(l))) return { kind: 'ol', items: lines.map((l) => inlineMarkdown(l.replace(NUMBER, ''))) };
|
|
27
|
+
return { kind: 'p', html: inlineMarkdown(p) };
|
|
28
|
+
});
|
|
29
|
+
}
|
package/lib/video.ts
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Video links → a privacy-friendly embed. Accepts what an editor would paste — a
|
|
3
|
+
* YouTube watch / share / shorts / live link, a channel's live page, or a Vimeo link —
|
|
4
|
+
* and returns the embed URL (youtube-nocookie / Vimeo `dnt=1`) plus a link to watch it
|
|
5
|
+
* on the provider. Anything else → null (the block shows a plain link instead).
|
|
6
|
+
*/
|
|
7
|
+
export interface VideoEmbed {
|
|
8
|
+
provider: 'YouTube' | 'Vimeo';
|
|
9
|
+
embedUrl: string;
|
|
10
|
+
watchUrl: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const YT_ID = /^[A-Za-z0-9_-]{11}$/;
|
|
14
|
+
const YT_CHANNEL = /^UC[A-Za-z0-9_-]{22}$/;
|
|
15
|
+
|
|
16
|
+
export function videoEmbed(input: string | undefined | null): VideoEmbed | null {
|
|
17
|
+
if (!input) return null;
|
|
18
|
+
let u: URL;
|
|
19
|
+
try {
|
|
20
|
+
u = new URL(input.trim());
|
|
21
|
+
} catch {
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
if (u.protocol !== 'https:') return null;
|
|
25
|
+
const host = u.hostname.replace(/^(www\.|m\.)/, '');
|
|
26
|
+
const parts = u.pathname.split('/').filter(Boolean);
|
|
27
|
+
const watchUrl = u.toString();
|
|
28
|
+
|
|
29
|
+
if (host === 'youtu.be' && YT_ID.test(parts[0] ?? '')) return yt(parts[0], watchUrl);
|
|
30
|
+
if (host === 'youtube.com' || host === 'youtube-nocookie.com') {
|
|
31
|
+
const v = u.searchParams.get('v');
|
|
32
|
+
if (parts[0] === 'watch' && v && YT_ID.test(v)) return yt(v, watchUrl);
|
|
33
|
+
if (['embed', 'shorts', 'live', 'v'].includes(parts[0]) && YT_ID.test(parts[1] ?? '')) return yt(parts[1], watchUrl);
|
|
34
|
+
// A channel's live stream: /channel/<UC…>/live, or an embed of live_stream?channel=…
|
|
35
|
+
const channel = parts[0] === 'channel' && parts[2] === 'live' ? parts[1] : u.searchParams.get('channel');
|
|
36
|
+
if (channel && YT_CHANNEL.test(channel)) {
|
|
37
|
+
return {
|
|
38
|
+
provider: 'YouTube',
|
|
39
|
+
embedUrl: `https://www.youtube.com/embed/live_stream?channel=${channel}`,
|
|
40
|
+
watchUrl: `https://www.youtube.com/channel/${channel}/live`,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
if (host === 'vimeo.com' && /^\d+$/.test(parts[0] ?? '')) {
|
|
46
|
+
return { provider: 'Vimeo', embedUrl: `https://player.vimeo.com/video/${parts[0]}?dnt=1`, watchUrl };
|
|
47
|
+
}
|
|
48
|
+
if (host === 'player.vimeo.com' && parts[0] === 'video' && /^\d+$/.test(parts[1] ?? '')) {
|
|
49
|
+
return { provider: 'Vimeo', embedUrl: `https://player.vimeo.com/video/${parts[1]}?dnt=1`, watchUrl: `https://vimeo.com/${parts[1]}` };
|
|
50
|
+
}
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function yt(id: string, watchUrl: string): VideoEmbed {
|
|
55
|
+
return { provider: 'YouTube', embedUrl: `https://www.youtube-nocookie.com/embed/${id}?rel=0`, watchUrl };
|
|
56
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ferst-core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Ferst Core — the shared, brand-agnostic client-site system: Astro components, layouts, content schemas, the layered config resolver, and a neutral styling architecture that every client overrides.",
|
|
6
6
|
"license": "BUSL-1.1",
|