ferst-core 0.7.0 → 0.7.4
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/components/BlockRenderer.astro +6 -0
- package/components/Pagination.astro +51 -0
- package/components/blocks/Heading.astro +47 -46
- package/components/blocks/Prose.astro +10 -2
- package/components/blocks/Quote.astro +40 -38
- package/components/recipes/Accordion.astro +91 -84
- package/components/recipes/ContactCard.astro +172 -170
- package/components/recipes/Document.astro +152 -0
- package/components/recipes/FeatureCards.astro +112 -111
- package/components/recipes/Hero.astro +120 -8
- package/components/recipes/LinkRow.astro +91 -0
- package/components/recipes/MediaCards.astro +213 -212
- package/components/recipes/Notice.astro +90 -89
- package/components/recipes/Stats.astro +152 -145
- package/components/recipes/Table.astro +98 -97
- package/components/recipes/Tabs.astro +134 -133
- package/components/recipes/Tiles.astro +287 -271
- package/components/recipes/Timetable.astro +189 -0
- package/components/recipes/Video.astro +177 -157
- package/content/blocks.ts +68 -2
- package/content/cookies.ts +10 -0
- package/examples/primitives.ts +2 -0
- package/examples/recipes.ts +42 -36
- package/integration.mjs +83 -64
- package/layouts/Base.astro +481 -475
- package/lib/documents.ts +68 -62
- package/lib/embed-consent.ts +78 -73
- package/lib/inline-markdown.ts +12 -0
- package/lib/links.ts +12 -0
- package/lib/paginate.ts +23 -0
- package/lib/posts.ts +20 -2
- package/lib/timetable.ts +27 -0
- package/lib/video.ts +101 -56
- package/package.json +1 -1
- package/routes/cookie-policy.astro +27 -0
- package/routes/posts/index.astro +36 -31
- package/routes/posts/page/[page].astro +36 -0
- package/routes/posts/tag/[...tag].astro +41 -37
package/examples/primitives.ts
CHANGED
|
@@ -9,6 +9,7 @@ export const primitives: GalleryEntry[] = [
|
|
|
9
9
|
{ type: 'heading', description: 'A section title, levels 1–6, optionally aligned.', block: { type: 'heading', text: 'A heading', level: 2 } },
|
|
10
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
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: 'prose', description: 'The `lead` variant: the paragraph straight under the hero that introduces the page, larger and quieter than body text.', block: { type: 'prose', variant: 'lead', text: 'A lead paragraph introduces the page and invites the reader in.' } },
|
|
12
13
|
{ 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
14
|
{ 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
15
|
{ 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' } },
|
|
@@ -17,6 +18,7 @@ export const primitives: GalleryEntry[] = [
|
|
|
17
18
|
{ type: 'button', description: 'Filled CTA — the brand-accent call to action.', block: { type: 'button', label: 'Get started', href: '#', variant: 'cta' } },
|
|
18
19
|
{ type: 'button', description: 'Ghost — a quiet, borderless text button for tertiary actions.', block: { type: 'button', label: 'Learn more', href: '#', variant: 'ghost' } },
|
|
19
20
|
{ type: 'quote', description: 'A pull quote with an optional citation.', block: { type: 'quote', text: 'A memorable quotation, set apart.', cite: 'Someone' } },
|
|
21
|
+
{ type: 'quote', description: 'A prayer, verse or poem: line breaks are kept as written, and inline formatting works.', block: { type: 'quote', text: 'Lord, make me an instrument of your peace:\nwhere there is hatred, let me sow love;\nwhere there is injury, *pardon*.', cite: 'Prayer of St Francis' } },
|
|
20
22
|
{ type: 'divider', description: 'A horizontal rule between sections.', block: { type: 'divider' } },
|
|
21
23
|
{ type: 'spacer', description: 'Vertical space (sm / md / lg) — invisible by design.', block: { type: 'spacer', size: 'lg' } },
|
|
22
24
|
{ 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.' }] } },
|
package/examples/recipes.ts
CHANGED
|
@@ -1,36 +1,42 @@
|
|
|
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: '
|
|
8
|
-
{ type: '
|
|
9
|
-
{ type: '
|
|
10
|
-
{ type: '
|
|
11
|
-
{ type: '
|
|
12
|
-
{ type: '
|
|
13
|
-
{ type: '
|
|
14
|
-
{ type: '
|
|
15
|
-
{ type: 'tiles', description: '
|
|
16
|
-
{ type: 'tiles', description: 'The “
|
|
17
|
-
{ type: 'tiles', description: '
|
|
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
|
-
{ type: '
|
|
33
|
-
{ type: '
|
|
34
|
-
{ type: '
|
|
35
|
-
{ type: '
|
|
36
|
-
|
|
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: 'video', description: 'A channel’s live stream: it doesn’t auto-load (an off-air channel shows “unavailable”), so visitors see the schedule note, a link to the channel’s streams, and “Show the live stream”.', block: { type: 'video', title: 'Watch Mass live', url: 'https://www.youtube.com/channel/UCabcdefghijklmnopqrstuv/live', note: 'Live: **Sundays 10.15am** and holy days.' } },
|
|
37
|
+
{ 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' }] } },
|
|
38
|
+
{ type: 'linkRow', description: 'A titled row of contact links as pills: phone, email and web links each get the right icon from the link itself. With an optional intro and footnote. For a contact who doesn’t need a whole contact card.', block: { type: 'linkRow', title: 'Vocations', intro: 'Thinking about a vocation? Talk to the vocations director.', links: [{ label: '01234 567890', href: 'tel:01234567890' }, { label: 'vocations@example.org', href: 'mailto:vocations@example.org' }, { label: 'Vocations website', href: 'https://example.com/vocations' }], note: 'All enquiries are treated in confidence.' } },
|
|
39
|
+
{ type: 'document', description: 'A policy or legal text as one block: sections numbered automatically, a contents list linking to each (sticky beside the text on wide screens), a “last updated” date, prose formatting in each section, print-friendly.', block: { type: 'document', intro: 'These conditions apply to every booking.', updated: '2026-10-04', sections: [{ title: 'Use of the hall', body: 'The hall may be used only for the purpose stated on the booking form.' }, { title: 'Bookings', body: 'Bookings are confirmed once the deposit is received.', items: ['Book through the office.', 'Bookings can’t be transferred.'] }, { title: 'Cancellation', body: 'Cancellations made less than **seven days** before the event forfeit the deposit.' }] } },
|
|
40
|
+
{ type: 'timetable', description: 'Weekly times for one or more places (service times, opening hours, sessions): a card per place, days with no times left out, notes with links under each. Set `layout: "table"` to compare places side by side.', block: { type: 'timetable', title: 'Service times', places: [{ name: "St Placeholder's", address: '1 Example Street', schedule: [{ day: 'Saturday', times: ['6:00pm (vigil)'] }, { day: 'Sunday', times: ['8:30am', '10:30am'] }, { day: 'Weekdays', times: ['9:30am'] }], notes: 'Confessions on Saturdays from 5pm. See [how to find us](#find-us).' }, { name: "St Sample's", schedule: [{ day: 'Saturday', times: [] }, { day: 'Sunday', times: ['9:00am'] }], notes: 'No Saturday service.' }] } },
|
|
41
|
+
{ type: 'timetable', description: 'The `table` layout: days down the side, one column per place; a day nobody uses is dropped.', block: { type: 'timetable', title: 'Opening hours', layout: 'table', places: [{ name: 'Library', schedule: [{ day: 'Monday', times: ['9am–5pm'] }, { day: 'Saturday', times: ['10am–1pm'] }] }, { name: 'Café', schedule: [{ day: 'Monday', times: ['8am–4pm'] }, { day: 'Sunday', times: [] }] }] } },
|
|
42
|
+
];
|
package/integration.mjs
CHANGED
|
@@ -1,64 +1,83 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* ferst() — the Astro integration that makes a client site's ROUTING package-owned.
|
|
3
|
-
*
|
|
4
|
-
* A thin client adds `integrations: [ferst()]` to astro.config.mjs and carries
|
|
5
|
-
* ZERO page files: the page catch-all (which also owns the home page), the
|
|
6
|
-
* calendar route and the blog routes are injected from ferst-core. A routing fix
|
|
7
|
-
* then ships as a package bump + rebuild — the same channel component fixes travel
|
|
8
|
-
* through — instead of being hand-copied into every client repo.
|
|
9
|
-
*
|
|
10
|
-
* Irreducible caveat (Astro 5): content collections must be defined in the CONSUMER,
|
|
11
|
-
* so the one-line `src/content/config.ts` re-export stays. "Zero page files" is
|
|
12
|
-
* reachable; "zero files" is not. Explicit client `.astro` routes still win over
|
|
13
|
-
* these injected ones, so a one-off hand-built page is always possible.
|
|
14
|
-
*
|
|
15
|
-
* The blog routes (posts index / individual post / tag pages) inject as a set:
|
|
16
|
-
* the listing components (NewsList, PostsFilter) now live in core, so `/posts`,
|
|
17
|
-
* `/posts/[...slug]` and `/posts/tag/[...tag]` all ship from the package. Turn
|
|
18
|
-
* the whole capability off with `blog: false` (then a client wanting posts hand-
|
|
19
|
-
* builds its own routes over `ferst-core/lib/posts`).
|
|
20
|
-
*
|
|
21
|
-
* @param {object} [options]
|
|
22
|
-
* @param {boolean} [options.calendar=true] Inject the `/calendar` route.
|
|
23
|
-
* @param {boolean} [options.blog=true] Inject the blog routes (`/posts`,
|
|
24
|
-
* `/posts/[...slug]`, `/posts/tag/[...tag]`).
|
|
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
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* ferst() — the Astro integration that makes a client site's ROUTING package-owned.
|
|
3
|
+
*
|
|
4
|
+
* A thin client adds `integrations: [ferst()]` to astro.config.mjs and carries
|
|
5
|
+
* ZERO page files: the page catch-all (which also owns the home page), the
|
|
6
|
+
* calendar route and the blog routes are injected from ferst-core. A routing fix
|
|
7
|
+
* then ships as a package bump + rebuild — the same channel component fixes travel
|
|
8
|
+
* through — instead of being hand-copied into every client repo.
|
|
9
|
+
*
|
|
10
|
+
* Irreducible caveat (Astro 5): content collections must be defined in the CONSUMER,
|
|
11
|
+
* so the one-line `src/content/config.ts` re-export stays. "Zero page files" is
|
|
12
|
+
* reachable; "zero files" is not. Explicit client `.astro` routes still win over
|
|
13
|
+
* these injected ones, so a one-off hand-built page is always possible.
|
|
14
|
+
*
|
|
15
|
+
* The blog routes (posts index / individual post / tag pages) inject as a set:
|
|
16
|
+
* the listing components (NewsList, PostsFilter) now live in core, so `/posts`,
|
|
17
|
+
* `/posts/[...slug]` and `/posts/tag/[...tag]` all ship from the package. Turn
|
|
18
|
+
* the whole capability off with `blog: false` (then a client wanting posts hand-
|
|
19
|
+
* builds its own routes over `ferst-core/lib/posts`).
|
|
20
|
+
*
|
|
21
|
+
* @param {object} [options]
|
|
22
|
+
* @param {boolean} [options.calendar=true] Inject the `/calendar` route.
|
|
23
|
+
* @param {boolean} [options.blog=true] Inject the blog routes (`/posts`,
|
|
24
|
+
* `/posts/[...slug]`, `/posts/tag/[...tag]`).
|
|
25
|
+
* @param {boolean} [options.cookiePolicy=true] Inject `/cookie-policy` (the page the cookie
|
|
26
|
+
* banner links to), generated from the blocks the site uses. Skipped automatically when
|
|
27
|
+
* the site has its own `src/pages/cookie-policy.astro`.
|
|
28
|
+
* @returns {import('astro').AstroIntegration}
|
|
29
|
+
*/
|
|
30
|
+
import { existsSync } from 'node:fs';
|
|
31
|
+
|
|
32
|
+
export default function ferst(options = {}) {
|
|
33
|
+
const { calendar = true, blog = true, cookiePolicy = true } = options;
|
|
34
|
+
return {
|
|
35
|
+
name: 'ferst',
|
|
36
|
+
hooks: {
|
|
37
|
+
'astro:config:setup': ({ injectRoute, config }) => {
|
|
38
|
+
// Pages + home: the reserved `index` entry renders at `/`, every other
|
|
39
|
+
// `pages` entry at its own slug (see lib/pages.ts `getSiteRoutes`).
|
|
40
|
+
injectRoute({
|
|
41
|
+
pattern: '/[...slug]',
|
|
42
|
+
entrypoint: 'ferst-core/routes/[...slug].astro',
|
|
43
|
+
});
|
|
44
|
+
if (calendar) {
|
|
45
|
+
injectRoute({
|
|
46
|
+
pattern: '/calendar',
|
|
47
|
+
entrypoint: 'ferst-core/routes/calendar/index.astro',
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
if (blog) {
|
|
51
|
+
// The posts capability: index, individual post, and tag pages. Reads
|
|
52
|
+
// the `posts`/`tags`/`postsSettings` collections (content/collections.ts).
|
|
53
|
+
injectRoute({
|
|
54
|
+
pattern: '/posts',
|
|
55
|
+
entrypoint: 'ferst-core/routes/posts/index.astro',
|
|
56
|
+
});
|
|
57
|
+
injectRoute({
|
|
58
|
+
pattern: '/posts/[...slug]',
|
|
59
|
+
entrypoint: 'ferst-core/routes/posts/[...slug].astro',
|
|
60
|
+
});
|
|
61
|
+
injectRoute({
|
|
62
|
+
pattern: '/posts/tag/[...tag]',
|
|
63
|
+
entrypoint: 'ferst-core/routes/posts/tag/[...tag].astro',
|
|
64
|
+
});
|
|
65
|
+
// Index pages 2+ (page 1 is /posts; tag pages paginate inside their own route).
|
|
66
|
+
injectRoute({
|
|
67
|
+
pattern: '/posts/page/[page]',
|
|
68
|
+
entrypoint: 'ferst-core/routes/posts/page/[page].astro',
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
// The cookie banner links here on every page, so it must always resolve. A site's
|
|
72
|
+
// own hand-built page wins (Astro would otherwise see two routes for one path).
|
|
73
|
+
const bespoke = config?.root && existsSync(new URL('src/pages/cookie-policy.astro', config.root));
|
|
74
|
+
if (cookiePolicy && !bespoke) {
|
|
75
|
+
injectRoute({
|
|
76
|
+
pattern: '/cookie-policy',
|
|
77
|
+
entrypoint: 'ferst-core/routes/cookie-policy.astro',
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
},
|
|
81
|
+
},
|
|
82
|
+
};
|
|
83
|
+
}
|