feat: convert remaining Landing Page blocks from Go HTML to React

This commit is contained in:
George Cushen committed 2026-02-18 19:21:54 +00:00
1 parent 266d092fac
commit ef8a34e33a
26 files changed
+1088 -1063

No files matched your search

-178
View File
@@ -1,178 +0,0 @@
{{/* Hugo Blox: CTA Card */}}
{{/* Documentation: https://hugoblox.com/blocks/ */}}
{{/* License: https://github.com/HugoBlox/kit/blob/main/LICENSE.md */}}
{{/* Initialise */}}
{{ $page := .wcPage }}
{{ $block := .wcBlock }}
{{ $content := $block.content }}
{{ if not (reflect.IsMap $content) }}{{ $content = dict }}{{ end }}
{{ $design := $block.design }}
{{ if not (reflect.IsMap $design) }}{{ $design = dict }}{{ end }}
{{ $card := index $design "card" }}
{{ if not (reflect.IsMap $card) }}{{ $card = dict }}{{ end }}
{{ $title := "" }}
{{ with index $content "title" }}{{ $title = printf "%v" . | emojify | $page.RenderString }}{{ end }}
{{ $text := "" }}
{{ with index $content "text" }}{{ $text = printf "%v" . | emojify | $page.RenderString | safeHTML }}{{ end }}
{{ $card_class := "" }}
{{ $card_class_raw := index $card "css_class" }}
{{ if eq (printf "%T" $card_class_raw) "string" }}
{{ $card_class = $card_class_raw }}
{{ end }}
{{ $card_style := "" }}
{{ $card_style_raw := index $card "css_style" }}
{{ if eq (printf "%T" $card_style_raw) "string" }}
{{ $card_style = $card_style_raw }}
{{ end }}
{{/* Contrast and accessibility controls */}}
{{ $text_color_mode := "auto" }}
{{ $text_color_raw := index $card "text_color" }}
{{ if eq (printf "%T" $text_color_raw) "string" }}
{{ $text_color_candidate := lower (strings.TrimSpace $text_color_raw) }}
{{ if in (slice "auto" "light" "dark") $text_color_candidate }}
{{ $text_color_mode = $text_color_candidate }}
{{ end }}
{{ end }}
{{ $overlay_opacity := 0.15 }}
{{ $overlay_raw := index $card "overlay_opacity" }}
{{ if ne $overlay_raw nil }}
{{ $overlay_type := printf "%T" $overlay_raw }}
{{ if or (eq $overlay_type "int") (eq $overlay_type "int8") (eq $overlay_type "int16") (eq $overlay_type "int32") (eq $overlay_type "int64") (eq $overlay_type "uint") (eq $overlay_type "uint8") (eq $overlay_type "uint16") (eq $overlay_type "uint32") (eq $overlay_type "uint64") (eq $overlay_type "float32") (eq $overlay_type "float64") }}
{{ $overlay_opacity = float $overlay_raw }}
{{ else if eq $overlay_type "string" }}
{{ $overlay_trimmed := strings.TrimSpace $overlay_raw }}
{{ if gt (len (findRE "^-?[0-9]*\\.?[0-9]+$" $overlay_trimmed)) 0 }}
{{ $overlay_opacity = float $overlay_trimmed }}
{{ end }}
{{ end }}
{{ end }}
{{ if lt $overlay_opacity 0 }}{{ $overlay_opacity = 0 }}{{ end }}
{{ if gt $overlay_opacity 1 }}{{ $overlay_opacity = 1 }}{{ end }}
{{/* Auto-detect text color based on background */}}
{{ $text_classes := "" }}
{{ $button_classes := "" }}
{{ if eq $text_color_mode "light" }}
{{ $text_classes = "text-white" }}
{{ $button_classes = "text-gray-900 dark:text-white" }}
{{ else if eq $text_color_mode "dark" }}
{{ $text_classes = "text-gray-900 dark:text-white" }}
{{ $button_classes = "text-gray-900 dark:text-white" }}
{{ else }}
{{/* Auto mode - detect based on gradient colors */}}
{{ $is_dark_bg := false }}
{{ $gradient_start := "" }}
{{ $bg := index $design "background" }}
{{ if and $bg (reflect.IsMap $bg) }}
{{ $gradient := index $bg "gradient" }}
{{ if and $gradient (reflect.IsMap $gradient) }}
{{ $start_raw := index $gradient "start" }}
{{ if eq (printf "%T" $start_raw) "string" }}
{{ $gradient_start = $start_raw }}
{{ end }}
{{ end }}
{{ end }}
{{ if $gradient_start }}
{{ if or (hasPrefix $gradient_start "primary-7") (hasPrefix $gradient_start "primary-8") (hasPrefix $gradient_start "primary-9") (hasPrefix $gradient_start "secondary-7") (hasPrefix $gradient_start "secondary-8") (hasPrefix $gradient_start "secondary-9") }}
{{ $is_dark_bg = true }}
{{ end }}
{{ end }}
{{ if $is_dark_bg }}
{{ $text_classes = "text-white" }}
{{ $button_classes = "text-gray-900" }}
{{ else }}
{{ $text_classes = "text-gray-900 dark:text-white" }}
{{ $button_classes = "text-gray-900 dark:text-gray-100" }}
{{ end }}
{{ end }}
{{/* Modern 2025 CTA Card - Background controlled via design.background in front matter */}}
{{ $custom_style := printf "--glassmorphism-opacity: %s;" (string $overlay_opacity) }}
{{ if $card_style }}
{{ $custom_style = printf "%s%s" $custom_style $card_style }}
{{ end }}
<div class="relative overflow-hidden {{with $card_class}}{{.}}{{else}}bg-gradient-to-br from-primary-500/90 via-primary-600/95 to-primary-700/90{{end}} p-8 sm:p-12 lg:p-16 xl:p-20 mx-auto max-w-6xl rounded-3xl shadow-2xl flex flex-col items-center text-center"
style="{{ $custom_style | safeCSS }}">
{{/* Modern Typography Hierarchy - Responsive text colors */}}
{{ with $title }}
<h2 class="{{ $text_classes }} text-4xl sm:text-5xl lg:text-6xl xl:text-7xl font-bold tracking-tight leading-tight">
{{ . }}
</h2>
{{ end }}
{{/* Enhanced Text with Better Spacing - Responsive colors */}}
{{ with $text }}
<div class="{{ $text_classes }}/80 mt-6 text-lg sm:text-xl lg:text-2xl max-w-3xl leading-relaxed font-light">
{{ . }}
</div>
{{ end }}
{{ $button_raw := index $content "button" }}
{{ if and $button_raw (reflect.IsMap $button_raw) }}
{{ $button_text := "" }}
{{ $button_url := "" }}
{{ $button_icon := "" }}
{{ $button_new_tab := partial "functions/coerce_bool" (dict "value" (index $button_raw "new_tab") "default" false) }}
{{ with index $button_raw "text" }}{{ $button_text = printf "%v" . }}{{ end }}
{{ with index $button_raw "url" }}{{ $button_url = printf "%v" . }}{{ end }}
{{ with index $button_raw "icon" }}
{{ if eq (printf "%T" .) "string" }}
{{ $button_icon = . }}
{{ end }}
{{ end }}
{{ $button_text = strings.TrimSpace $button_text }}
{{ $button_url = strings.TrimSpace $button_url }}
{{ if and $button_text $button_url }}
{{/* Modern 2025 Button Container */}}
<div class="flex mt-10">
{{ $button_link := $button_url }}
{{ $button_scheme := (urls.Parse $button_link).Scheme }}
{{ $button_target := "" }}
{{ if not $button_scheme }}
{{/* Check if it's a hash fragment (page anchor) */}}
{{ if not (hasPrefix $button_link "#") }}
{{/* Apply relLangURL for relative paths */}}
{{ $button_link = $button_link | relLangURL }}
{{ end }}
{{ if eq (path.Ext $button_link) ".pdf" }}
{{ $button_new_tab = true }}
{{ end }}
{{ else if in (slice "http" "https") $button_scheme }}
{{ $button_new_tab = true }}
{{ end }}
{{/* Apply new tab if requested or external link */}}
{{ if $button_new_tab }}
{{ $button_target = "target=\"_blank\" rel=\"noopener\"" }}
{{ end }}
{{/* 2025 Glassmorphism Button */}}
<a href="{{ $button_link | safeURL }}"
{{ $button_target | safeHTMLAttr }}
class="group relative inline-flex items-center gap-3 px-8 py-4 text-lg font-semibold {{ $button_classes }} transition-all duration-300 ease-out hover:scale-105 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-gray-500/50 dark:focus-visible:ring-white/50 focus-visible:ring-offset-2 focus-visible:ring-offset-transparent">
{{/* Button Background with Glassmorphism */}}
<div class="absolute inset-0 bg-white/95 dark:bg-gray-900/95 backdrop-blur-md rounded-2xl ring-1 ring-gray-200/30 dark:ring-white/30 shadow-lg group-hover:bg-white dark:group-hover:bg-gray-800 group-hover:ring-gray-300/50 dark:group-hover:ring-white/50 group-hover:shadow-2xl transition-all duration-300"></div>
{{/* Button Content */}}
<span class="relative z-10">{{ $button_text }}</span>
{{ if $button_icon }}
<span class="relative z-10 transition-transform duration-300 group-hover:translate-x-1">
{{ partial "functions/get_icon" (dict "name" $button_icon "attributes" "style=\"height: 1.25em\" class='inline-block'") }}
</span>
{{ end }}
{{/* Subtle glow effect */}}
<div class="absolute inset-0 bg-white/20 dark:bg-white/10 rounded-2xl opacity-0 group-hover:opacity-100 transition-opacity duration-300 blur-xl"></div>
</a>
</div>
{{ end }}
{{ end }}
</div>
+25
View File
@@ -0,0 +1,25 @@
import { render } from "preact";
import { CtaCardBlock } from "./component.jsx";
function renderCtaCardBlock() {
const blocks = document.querySelectorAll('.cta-card-block-container[data-preact-render="true"]');
blocks.forEach((block) => {
try {
if (block.dataset.rendered === "true") return;
const props = JSON.parse(block.dataset.props);
render(<CtaCardBlock {...props} />, block);
block.dataset.rendered = "true";
} catch (e) {
console.error("Error rendering CTA Card Block:", e, block);
}
});
}
// Initial render
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", renderCtaCardBlock);
} else {
renderCtaCardBlock();
}
+128
View File
@@ -0,0 +1,128 @@
/**
* CTA Card Block Component
* A centered card with title, text, and primary action button.
* Supports glassmorphism, custom backgrounds, and automatic text color adjustment.
*/
import { Icon } from "../../shared/components/Icon.jsx";
// Simple markdown renderer
function renderText(text) {
if (!text) return "";
return String(text)
.replace(/\*\*(.*?)\*\*/g, "<strong>$1</strong>")
.replace(/\*(.*?)\*/g, "<em>$1</em>")
.replace(/`(.*?)`/g, "<code>$1</code>")
.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2">$1</a>');
}
export const CtaCardBlock = ({ content, design, id, button_icon_svg }) => {
const card = design?.card || {};
const cardClass = card.css_class || "";
const cardStyle = card.css_style || "";
// Overlay Opacity Logic
let overlayOpacity = 0.15;
if (card.overlay_opacity !== undefined) {
const val = parseFloat(card.overlay_opacity);
if (!isNaN(val)) overlayOpacity = Math.max(0, Math.min(1, val));
}
// Text Color Logic
let textColorMode = "auto";
if (["light", "dark", "auto"].includes(card.text_color?.toLowerCase())) {
textColorMode = card.text_color.toLowerCase();
}
let textClasses = "text-gray-900 dark:text-white";
let buttonClasses = "text-gray-900 dark:text-gray-100";
// Auto-detect based on background gradient
const bg = design?.background || {};
const gradientStart = bg.gradient?.start || "";
const isDarkBg = ["primary-7", "primary-8", "primary-9", "secondary-7", "secondary-8", "secondary-9"].some(p => gradientStart.startsWith(p));
if (textColorMode === "light") {
textClasses = "text-white";
buttonClasses = "text-gray-900 dark:text-white";
} else if (textColorMode === "dark") {
textClasses = "text-gray-900 dark:text-white";
buttonClasses = "text-gray-900 dark:text-white";
} else if (isDarkBg) {
textClasses = "text-white";
buttonClasses = "text-gray-900";
}
// Button Logic
const button = content?.button || {};
const buttonText = button.text?.trim();
const buttonUrl = button.url?.trim();
const showButton = !!(buttonText && buttonUrl);
let isExternal = false;
let isNewTab = button.new_tab === true;
if (buttonUrl) {
try {
const url = new URL(buttonUrl, window.location.origin);
isExternal = url.origin !== window.location.origin;
} catch {
isExternal = /^https?:\/\//.test(buttonUrl);
}
if (buttonUrl.endsWith(".pdf")) isNewTab = true;
if (isExternal) isNewTab = true;
}
const defaultBgClass = "bg-gradient-to-br from-primary-500/90 via-primary-600/95 to-primary-700/90";
const finalCardClass = cardClass || defaultBgClass;
const style = `--glassmorphism-opacity: ${overlayOpacity}; ${cardStyle}`;
return (
<div
class={`relative overflow-hidden ${finalCardClass} p-8 sm:p-12 lg:p-16 xl:p-20 mx-auto max-w-6xl rounded-3xl shadow-2xl flex flex-col items-center text-center`}
style={style}
>
{/* Title */}
{content.title && (
<h2
class={`${textClasses} text-4xl sm:text-5xl lg:text-6xl xl:text-7xl font-bold tracking-tight leading-tight`}
dangerouslySetInnerHTML={{ __html: renderText(content.title) }}
/>
)}
{/* Text */}
{content.text && (
<div
class={`${textClasses}/80 mt-6 text-lg sm:text-xl lg:text-2xl max-w-3xl leading-relaxed font-light`}
dangerouslySetInnerHTML={{ __html: renderText(content.text) }}
/>
)}
{/* Button */}
{showButton && (
<div class="flex mt-10">
<a
href={buttonUrl}
target={isNewTab ? "_blank" : undefined}
rel={isNewTab ? "noopener" : undefined}
class={`group relative inline-flex items-center gap-3 px-8 py-4 text-lg font-semibold ${buttonClasses} transition-all duration-300 ease-out hover:scale-105 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-gray-500/50 dark:focus-visible:ring-white/50 focus-visible:ring-offset-2 focus-visible:ring-offset-transparent`}
>
{/* Glassmorphism Background */}
<div class="absolute inset-0 bg-white/95 dark:bg-gray-900/95 backdrop-blur-md rounded-2xl ring-1 ring-gray-200/30 dark:ring-white/30 shadow-lg group-hover:bg-white dark:group-hover:bg-gray-800 group-hover:ring-gray-300/50 dark:group-hover:ring-white/50 group-hover:shadow-2xl transition-all duration-300"></div>
{/* Button Content */}
<span class="relative z-10">{buttonText}</span>
{button_icon_svg && (
<span class="relative z-10 transition-transform duration-300 group-hover:translate-x-1">
<Icon svg={button_icon_svg} attributes={{ style: "height: 1.25em", class: "inline-block" }} />
</span>
)}
{/* Glow Effect */}
<div class="absolute inset-0 bg-white/20 dark:bg-white/10 rounded-2xl opacity-0 group-hover:opacity-100 transition-opacity duration-300 blur-xl"></div>
</a>
</div>
)}
</div>
);
};
@@ -1,151 +0,0 @@
{{/* Hugo Blox: CTA Image Paragraph */}}
{{/* Documentation: https://hugoblox.com/blocks/ */}}
{{/* License: https://github.com/HugoBlox/kit/blob/main/LICENSE.md */}}
{{/* Initialise */}}
{{ $page := .wcPage }}
{{ $block := .wcBlock }}
{{ $content := $block.content }}
{{ if not (reflect.IsMap $content) }}{{ $content = dict }}{{ end }}
<div>
{{ $items_raw := index $content "items" }}
{{ $items := slice }}
{{ if reflect.IsSlice $items_raw }}
{{ $items = $items_raw }}
{{ else if and $items_raw (reflect.IsMap $items_raw) }}
{{ $items = slice $items_raw }}
{{ end }}
{{ range $idx, $item := $items }}
{{ if reflect.IsMap $item }}
{{ $title := "" }}
{{ $title_raw := index $item "title" }}
{{ if eq (printf "%T" $title_raw) "string" }}{{ $title = $title_raw | emojify | $page.RenderString }}{{ end }}
{{ $text := "" }}
{{ $text_raw := index $item "text" }}
{{ if eq (printf "%T" $text_raw) "string" }}{{ $text = $text_raw | emojify | $page.RenderString }}{{ end }}
{{ $image_name := "" }}
{{ $image_raw := index $item "image" }}
{{ if eq (printf "%T" $image_raw) "string" }}{{ $image_name = strings.TrimSpace $image_raw }}{{ end }}
{{/* Resolve image from page bundle, site assets, remote, or static URLs */}}
{{ $image_resource := false }}
{{ $image_url := "" }}
{{ $image_path := "" }}
{{ if $image_name }}
{{ $image_path = strings.TrimSpace (printf "%v" $image_name) }}
{{ $is_remote := or (strings.HasPrefix $image_path "http://") (strings.HasPrefix $image_path "https://") }}
{{ if $is_remote }}
{{ $remote := try (resources.GetRemote $image_path) }}
{{ if and $remote (not $remote.Err) }}
{{ $image_resource = $remote.Value }}
{{ else }}
{{ $image_url = $image_path }}
{{ end }}
{{ else }}
{{ $image_path = strings.TrimPrefix "/" $image_path }}
{{ $image_path = strings.TrimPrefix "./" $image_path }}
{{ $image_path = strings.TrimPrefix "assets/" $image_path }}
{{ $image_path = strings.TrimPrefix "media/" $image_path }}
{{ $normalized := path.Clean $image_path }}
{{ if and $normalized (ne $normalized ".") }}
{{ with $page }}
{{ $image_resource = (.Resources.ByType "image").GetMatch $normalized }}
{{ end }}
{{ if not $image_resource }}
{{ $image_resource = resources.Get (path.Join "media" $normalized) }}
{{ end }}
{{ if not $image_resource }}
{{ $image_url = printf "/media/%s" $normalized }}
{{ end }}
{{ end }}
{{ end }}
{{ end }}
{{ $image_to_render := $image_resource }}
<div class="gap-8 items-center py-8 px-4 mx-auto max-w-screen-xl xl:gap-16 {{ if or $image_to_render $image_url }}md:grid md:grid-cols-2{{ end }} sm:py-16 lg:px-6">
{{ if or $image_to_render $image_url }}
{{ if $image_to_render }}
{{ if ne $image_to_render.MediaType.SubType "gif" }}
{{ $responsive := partial "functions/process_responsive_image.html" (dict
"image" $image_to_render
"mode" "fit"
"sizes" (slice 400 600 800 1200)
) }}
<img class="w-full"
srcset="{{ $responsive.srcset }}"
sizes="(max-width: 768px) 100vw, 50vw"
src="{{ $responsive.fallback.RelPermalink }}"
width="{{ $responsive.fallback.Width }}"
height="{{ $responsive.fallback.Height }}"
alt="{{$title | plainify}}" {{ if (modBool $idx 2)}}style="order: 1"{{end}}>
{{ else }}
<img class="w-full"
src="{{$image_to_render.RelPermalink}}"
width="{{$image_to_render.Width}}"
height="{{$image_to_render.Height}}"
alt="{{$title | plainify}}" {{ if (modBool $idx 2)}}style="order: 1"{{end}}>
{{ end }}
{{ else if $image_url }}
{{ $is_external := or (strings.HasPrefix $image_url "http://") (strings.HasPrefix $image_url "https://") }}
<img class="w-full"
src="{{ if $is_external }}{{ $image_url }}{{ else }}{{ $image_url | relURL }}{{ end }}"
alt="{{$title | plainify}}" {{ if (modBool $idx 2)}}style="order: 1"{{end}}>
{{ end }}
{{ else if $image_name }}
{{ warnf "%s uses the `cta-image-paragraph` blox which specifies a non-existent `image` at `assets/%s`. Rendering text only." $page.File.Path $image_path }}
{{ end }}
<div class="mt-4 md:mt-0">
<h2 class="mb-4 text-4xl tracking-tight font-extrabold text-gray-900 dark:text-white">{{$title}}</h2>
<p class="mb-6 font-light text-gray-500 md:text-lg dark:text-gray-400">{{$text}}</p>
{{ $icon := "check" }}
{{ $icon_raw := index $item "feature_icon" }}
{{ if eq (printf "%T" $icon_raw) "string" }}{{ $icon = $icon_raw }}{{ end }}
{{ $features_raw := index $item "features" }}
{{ $features := slice }}
{{ if reflect.IsSlice $features_raw }}
{{ $features = $features_raw }}
{{ else if eq (printf "%T" $features_raw) "string" }}
{{ $features = slice $features_raw }}
{{ end }}
{{ if gt (len $features) 0 }}
<ul>
{{ range $features }}
<li class="relative mb-4 pl-6">
{{ partial "functions/get_icon" (dict "name" $icon "attributes" `class="inline-block pr-3" style="height: 1em;"`) }}
{{ printf "%v" . | markdownify }}
</li>
{{ end }}
</ul>
{{ end }}
{{ $button_raw := index $item "button" }}
{{ if and $button_raw (reflect.IsMap $button_raw) }}
{{ $button_text := "" }}
{{ $button_url := "" }}
{{ with index $button_raw "text" }}{{ $button_text = strings.TrimSpace (printf "%v" .) }}{{ end }}
{{ with index $button_raw "url" }}{{ $button_url = strings.TrimSpace (printf "%v" .) }}{{ end }}
{{ if and $button_text $button_url }}
{{ $link := $button_url }}
{{ $target := "" }}
{{ $scheme := (urls.Parse $link).Scheme }}
{{ if not $scheme }}
{{ if not (hasPrefix $link "#") }}
{{ $link = $link | relLangURL }}
{{ end }}
{{ if eq (path.Ext $link) ".pdf" }}{{ $target = "target=\"_blank\" rel=\"noopener\"" }}{{ end }}
{{ else if in (slice "http" "https") $scheme }}
{{ $target = "target=\"_blank\" rel=\"noopener\"" }}
{{ end }}
<a href="{{ $link | safeURL }}" {{ $target | safeHTMLAttr }} class="mt-3 inline-flex items-center text-white bg-primary-700 hover:bg-primary-800 focus:ring-4 focus:ring-primary-300 font-medium rounded-lg text-sm px-5 py-2.5 text-center dark:focus:ring-primary-900">
{{ $button_text }}
<svg class="ml-2 -mr-1 w-5 h-5" fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M10.293 3.293a1 1 0 011.414 0l6 6a1 1 0 010 1.414l-6 6a1 1 0 01-1.414-1.414L14.586 11H3a1 1 0 110-2h11.586l-4.293-4.293a1 1 0 010-1.414z" clip-rule="evenodd"></path></svg>
</a>
{{ end }}
{{ end }}
</div>
</div>
{{ end }}
{{ end }}
</div>
@@ -0,0 +1,25 @@
/**
* CTA Image Paragraph Block - Client-side Hydration
*/
import { render } from "preact";
import { CtaImageParagraphBlock } from "./component.jsx";
function renderCtaImageParagraphBlocks() {
const blocks = document.querySelectorAll('[data-block-type="cta-image-paragraph"]');
blocks.forEach((block) => {
const propsData = block.dataset.props;
if (propsData) {
try {
const props = JSON.parse(propsData);
render(<CtaImageParagraphBlock {...props} />, block);
console.debug(`✓ CTA Image Paragraph block "${block.id}" rendered with Preact`);
} catch (error) {
console.error(`Failed to render CTA Image Paragraph block "${block.id}":`, error);
}
}
});
}
renderCtaImageParagraphBlocks();
@@ -0,0 +1,140 @@
/**
* CTA Image Paragraph Block Component - Single source of truth
* Alternating image + text sections with feature lists and CTA buttons
* Images are pre-processed by Hugo's responsive image pipeline (srcset)
*/
import { Icon } from "../../shared/components/Icon.jsx";
// Simple markdown renderer
function renderText(text) {
if (!text) return "";
return String(text)
.replace(/\*\*(.*?)\*\*/g, "<strong>$1</strong>")
.replace(/\*(.*?)\*/g, "<em>$1</em>")
.replace(/`(.*?)`/g, "<code>$1</code>")
.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2">$1</a>');
}
// Arrow icon for CTA buttons
const ArrowIcon = () => (
<svg class="ml-2 -mr-1 w-5 h-5" fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" d="M10.293 3.293a1 1 0 011.414 0l6 6a1 1 0 010 1.414l-6 6a1 1 0 01-1.414-1.414L14.586 11H3a1 1 0 110-2h11.586l-4.293-4.293a1 1 0 010-1.414z" clip-rule="evenodd" />
</svg>
);
function CtaItem({ item, idx, imageData, featureIconSvg }) {
const isReversed = idx % 2 === 1;
const hasImage = !!imageData;
const featureIcon = item.feature_icon || "check";
// Resolve button link
let buttonUrl = item.button?.url || "";
let isExternal = false;
if (buttonUrl) {
try {
const url = new URL(buttonUrl, window.location.origin);
isExternal = url.origin !== window.location.origin;
} catch {
isExternal = /^https?:\/\//.test(buttonUrl);
}
}
const features = Array.isArray(item.features)
? item.features
: typeof item.features === "string"
? [item.features]
: [];
return (
<div class={`gap-8 items-center py-8 px-4 mx-auto max-w-screen-xl xl:gap-16 ${hasImage ? "md:grid md:grid-cols-2" : ""} sm:py-16 lg:px-6`}>
{/* Image */}
{hasImage && (
imageData.srcset ? (
<img
class="w-full"
srcset={imageData.srcset}
sizes="(max-width: 768px) 100vw, 50vw"
src={imageData.src}
width={imageData.width}
height={imageData.height}
alt={item.title || ""}
style={isReversed ? { order: 1 } : undefined}
/>
) : (
<img
class="w-full"
src={imageData.src}
width={imageData.width}
height={imageData.height}
alt={item.title || ""}
style={isReversed ? { order: 1 } : undefined}
/>
)
)}
{/* Text Content */}
<div class="mt-4 md:mt-0">
{item.title && (
<h2
class="mb-4 text-4xl tracking-tight font-extrabold text-gray-900 dark:text-white"
dangerouslySetInnerHTML={{ __html: renderText(item.title) }}
/>
)}
{item.text && (
<p
class="mb-6 font-light text-gray-500 md:text-lg dark:text-gray-400"
dangerouslySetInnerHTML={{ __html: renderText(item.text) }}
/>
)}
{/* Feature List */}
{features.length > 0 && (
<ul>
{features.map((feature, fIdx) => (
<li key={fIdx} class="relative mb-4 pl-6">
{featureIconSvg ? (
<Icon svg={featureIconSvg} attributes={{ class: "inline-block pr-5", style: "height: 1em;" }} />
) : null}
<span dangerouslySetInnerHTML={{ __html: renderText(String(feature)) }} />
</li>
))}
</ul>
)}
{/* CTA Button */}
{item.button?.text && item.button?.url && (
<a
href={buttonUrl}
target={isExternal ? "_blank" : undefined}
rel={isExternal ? "noopener" : undefined}
class="mt-3 inline-flex items-center text-white bg-primary-700 hover:bg-primary-800 focus:ring-4 focus:ring-primary-300 font-medium rounded-lg text-sm px-5 py-2.5 text-center dark:focus:ring-primary-900"
>
{item.button.text}
<ArrowIcon />
</a>
)}
</div>
</div>
);
}
export const CtaImageParagraphBlock = ({ content, design, id, item_images, icon_svgs }) => {
const items = Array.isArray(content?.items) ? content.items : [];
const imageMap = item_images || {};
const iconMap = icon_svgs || {};
return (
<div>
{items.map((item, idx) => (
<CtaItem
key={idx}
item={item}
idx={idx}
imageData={imageMap[String(idx)] || null}
featureIconSvg={item.feature_icon ? iconMap[item.feature_icon] : iconMap["check"]}
/>
))}
</div>
);
};
-52
View File
@@ -1,52 +0,0 @@
{{/* Hugo Blox: Features */}}
{{/* Documentation: https://hugoblox.com/blocks/ */}}
{{/* License: https://github.com/HugoBlox/kit/blob/main/LICENSE.md */}}
{{/* Initialise */}}
{{ $page := .wcPage }}
{{ $block := .wcBlock }}
{{ $content := $block.content }}
{{ if not (reflect.IsMap $content) }}{{ $content = dict }}{{ end }}
{{ $title := "" }}
{{ with index $content "title" }}{{ $title = printf "%v" . | emojify | $page.RenderString }}{{ end }}
{{ $text := "" }}
{{ with index $content "text" }}{{ $text = printf "%v" . | emojify | $page.RenderString }}{{ end }}
{{ $items_raw := index $content "items" }}
{{ $items := slice }}
{{ if reflect.IsSlice $items_raw }}{{ $items = $items_raw }}{{ end }}
<section>
<div class="py-8 px-4 mx-auto max-w-screen-xl sm:py-16 lg:px-6">
<div class="max-w-screen-md mb-8 lg:mb-16 flex flex-col justify-center mx-auto">
{{ with $title }}
<h2 class="mb-4 text-4xl tracking-tight font-extrabold text-gray-900 dark:text-white text-center">{{.}}</h2>
{{ end }}
{{ with $text }}
<p class="text-gray-500 sm:text-xl dark:text-gray-400 text-center">{{.}}</p>
{{ end }}
</div>
<div class="space-y-8 md:grid md:grid-cols-2 lg:grid-cols-3 md:gap-12 md:space-y-0">
{{ range $items }}
{{ if reflect.IsMap . }}
{{ $icon := "" }}
{{ with index . "icon" }}
{{ if eq (printf "%T" .) "string" }}{{ $icon = . }}{{ end }}
{{ end }}
{{ $name := "" }}
{{ with index . "name" }}{{ $name = printf "%v" . }}{{ end }}
{{ $description := "" }}
{{ with index . "description" }}{{ $description = printf "%v" . }}{{ end }}
<div>
{{ with $icon }}
<div class="flex justify-center items-center mb-4 w-10 h-10 rounded-full bg-primary-100 lg:h-12 lg:w-12 dark:bg-primary-900 text-primary-600 dark:text-primary-300">
{{ partial "functions/get_icon" (dict "name" . "attributes" `class="w-5 h-5 lg:w-6 lg:h-6"`) }}
</div>
{{ end }}
{{ with $name }}<h3 class="mb-2 text-xl font-bold dark:text-white">{{ . | $page.RenderString }}</h3>{{ end }}
{{ with $description }}<p class="text-gray-500 dark:text-gray-400">{{ . | $page.RenderString }}</p>{{ end }}
</div>
{{ end }}
{{ end }}
</div>
</div>
</section>
+25
View File
@@ -0,0 +1,25 @@
/**
* Features Block - Client-side Hydration
*/
import { render } from "preact";
import { FeaturesBlock } from "./component.jsx";
function renderFeaturesBlocks() {
const blocks = document.querySelectorAll('[data-block-type="features"]');
blocks.forEach((block) => {
const propsData = block.dataset.props;
if (propsData) {
try {
const props = JSON.parse(propsData);
render(<FeaturesBlock {...props} />, block);
console.debug(`✓ Features block "${block.id}" rendered with Preact`);
} catch (error) {
console.error(`Failed to render Features block "${block.id}":`, error);
}
}
});
}
renderFeaturesBlocks();
+81
View File
@@ -0,0 +1,81 @@
/**
* Features Block Component - Single source of truth
* Responsive grid of feature items with icon, name, and description
*/
import { Icon } from "../../shared/components/Icon.jsx";
// Simple markdown renderer
function renderText(text) {
if (!text) return "";
return String(text)
.replace(/\*\*(.*?)\*\*/g, "<strong>$1</strong>")
.replace(/\*(.*?)\*/g, "<em>$1</em>")
.replace(/`(.*?)`/g, "<code>$1</code>");
}
function FeatureItem({ item, iconSvg }) {
return (
<div>
{iconSvg && (
<div class="flex justify-center items-center mb-4 w-10 h-10 rounded-full bg-primary-100 lg:h-12 lg:w-12 dark:bg-primary-900 text-primary-600 dark:text-primary-300">
<Icon svg={iconSvg} attributes={{ class: "w-5 h-5 lg:w-6 lg:h-6" }} />
</div>
)}
{item.name && (
<h3
class="mb-2 text-xl font-bold dark:text-white"
dangerouslySetInnerHTML={{ __html: renderText(item.name) }}
/>
)}
{item.description && (
<p
class="text-gray-500 dark:text-gray-400"
dangerouslySetInnerHTML={{ __html: renderText(item.description) }}
/>
)}
</div>
);
}
export const FeaturesBlock = ({ content, design, id, icon_svgs }) => {
const title = content?.title;
const text = content?.text;
const items = Array.isArray(content?.items) ? content.items : [];
const iconMap = icon_svgs || {};
return (
<section>
<div class="py-8 px-4 mx-auto max-w-screen-xl sm:py-16 lg:px-6">
{/* Section Header */}
{(title || text) && (
<div class="max-w-screen-md mb-8 lg:mb-16 flex flex-col justify-center mx-auto">
{title && (
<h2
class="mb-4 text-4xl tracking-tight font-extrabold text-gray-900 dark:text-white text-center"
dangerouslySetInnerHTML={{ __html: renderText(title) }}
/>
)}
{text && (
<p
class="text-gray-500 sm:text-xl dark:text-gray-400 text-center"
dangerouslySetInnerHTML={{ __html: renderText(text) }}
/>
)}
</div>
)}
{/* Feature Grid */}
<div class="space-y-8 md:grid md:grid-cols-2 lg:grid-cols-3 md:gap-12 md:space-y-0">
{items.map((item, idx) => (
<FeatureItem
key={idx}
item={item}
iconSvg={item.icon ? iconMap[item.icon] : null}
/>
))}
</div>
</div>
</section>
);
};
-117
View File
@@ -1,117 +0,0 @@
{{/* Hugo Blox: Hero */}}
{{/* Documentation: https://hugoblox.com/blocks/ */}}
{{/* License: https://github.com/HugoBlox/kit/blob/main/LICENSE.md */}}
{{/* Initialise */}}
{{ $page := .wcPage }}
{{ $block := .wcBlock }}
{{ $content := $block.content }}
{{ if not (reflect.IsMap $content) }}{{ $content = dict }}{{ end }}
{{ $design := $block.design }}
{{ if not (reflect.IsMap $design) }}{{ $design = dict }}{{ end }}
{{ $no_padding := partial "functions/coerce_bool" (dict "value" (index $design "no_padding") "default" false) }}
{{ $announcement := index $content "announcement" }}
{{ if not (reflect.IsMap $announcement) }}{{ $announcement = dict }}{{ end }}
{{ $announcement_text := "" }}
{{ with index $announcement "text" }}{{ $announcement_text = strings.TrimSpace (printf "%v" .) }}{{ end }}
{{ $announcement_link := index $announcement "link" }}
{{ if not (reflect.IsMap $announcement_link) }}{{ $announcement_link = dict }}{{ end }}
{{ $announcement_link_text := "" }}
{{ with index $announcement_link "text" }}{{ $announcement_link_text = strings.TrimSpace (printf "%v" .) }}{{ end }}
{{ $announcement_link_url := "" }}
{{ with index $announcement_link "url" }}{{ $announcement_link_url = strings.TrimSpace (printf "%v" .) }}{{ end }}
{{ $primary_action := index $content "primary_action" }}
{{ if not (reflect.IsMap $primary_action) }}{{ $primary_action = dict }}{{ end }}
{{ $primary_text := "" }}
{{ with index $primary_action "text" }}{{ $primary_text = strings.TrimSpace (printf "%v" .) }}{{ end }}
{{ $primary_url := "" }}
{{ with index $primary_action "url" }}{{ $primary_url = strings.TrimSpace (printf "%v" .) }}{{ end }}
{{ $primary_icon := "" }}
{{ with index $primary_action "icon" }}{{ $primary_icon = printf "%v" . }}{{ end }}
{{ $secondary_action := index $content "secondary_action" }}
{{ if not (reflect.IsMap $secondary_action) }}{{ $secondary_action = dict }}{{ end }}
{{ $secondary_text := "" }}
{{ with index $secondary_action "text" }}{{ $secondary_text = strings.TrimSpace (printf "%v" .) }}{{ end }}
{{ $secondary_url := "" }}
{{ with index $secondary_action "url" }}{{ $secondary_url = strings.TrimSpace (printf "%v" .) }}{{ end }}
<div class="relative isolate px-6 pt-14 lg:px-8">
<div class="mx-auto max-w-2xl {{if not $no_padding }}py-32 sm:py-48 lg:py-56{{end}}">
{{ if $announcement_text }}
<div class="hidden sm:mb-8 sm:flex sm:justify-center">
<div class="relative rounded-full px-3 py-1 text-sm leading-6 text-gray-600 dark:text-gray-300 ring-1 ring-gray-900/10 dark:ring-gray-300 hover:ring-gray-900/20 dark:hover:ring-gray-400">
{{ $announcement_text | markdownify }}
{{ if and $announcement_link_text $announcement_link_url }}
{{ $link := $announcement_link_url }}
{{ $scheme := (urls.Parse $link).Scheme }}
{{ if not $scheme }}
{{ if not (hasPrefix $link "#") }}
{{ $link = $link | relLangURL }}
{{ end }}
{{ end }}
<a href="{{ $link | safeURL }}" class="font-semibold text-primary-600 dark:text-primary-300">
<span class="absolute inset-0" aria-hidden="true"></span>{{ $announcement_link_text }} <span aria-hidden="true">&rarr;</span>
</a>
{{end}}
</div>
</div>
{{end}}
<div class="text-center">
{{ with index $content "title" }}<h1 class="text-4xl font-bold tracking-tight text-gray-900 dark:text-gray-100 sm:text-6xl">{{ printf "%v" . | markdownify }}</h1>{{end}}
{{ with index $content "text" }}<p class="mt-6 text-lg leading-8 text-gray-600 dark:text-gray-300 max-w-2xl mx-auto">{{ printf "%v" . | $page.RenderString | emojify }}</p>{{end}}
{{ if and $primary_url $primary_text }}
{{ $link := $primary_url }}
{{ $scheme := (urls.Parse $link).Scheme }}
{{ $target := "" }}
{{ if not $scheme }}
{{/* Check if it's a hash fragment (page anchor) */}}
{{ if hasPrefix $link "#" }}
{{/* Keep hash fragments as-is */}}
{{ $link = $link }}
{{ else }}
{{/* Apply relLangURL for relative paths */}}
{{ $link = $link | relLangURL }}
{{ end }}
{{ else if in (slice "http" "https") $scheme }}
{{ $target = "target=\"_blank\" rel=\"noopener\"" }}
{{ end }}
<div class="mt-10 flex items-center justify-center gap-x-6">
<a href="{{ $link | safeURL }}" {{ $target | safeHTMLAttr }} class="rounded-md bg-primary-600 px-3.5 py-2.5 text-sm font-semibold text-white shadow-sm hover:bg-primary-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary-600">
{{ $primary_text | markdownify | emojify }}
{{ if $primary_icon }}
{{ partial "functions/get_icon" (dict "name" $primary_icon "attributes" "style=\"height: 1em\" class='inline-block pl-2'") }}
{{ end }}
</a>
{{end}}
{{ if and $secondary_url $secondary_text }}
{{ $link_alt := $secondary_url }}
{{ $scheme_alt := (urls.Parse $link_alt).Scheme }}
{{ $target_alt := "" }}
{{ if not $scheme_alt }}
{{/* Check if it's a hash fragment (page anchor) */}}
{{ if hasPrefix $link_alt "#" }}
{{/* Keep hash fragments as-is */}}
{{ $link_alt = $link_alt }}
{{ else }}
{{/* Apply relLangURL for relative paths */}}
{{ $link_alt = $link_alt | relLangURL }}
{{ end }}
{{ else if in (slice "http" "https") $scheme_alt }}
{{ $target_alt = "target=\"_blank\" rel=\"noopener\"" }}
{{ end }}
<a href="{{ $link_alt | safeURL }}" {{ $target_alt | safeHTMLAttr }} class="text-sm font-semibold leading-6 text-gray-900 dark:text-gray-100 hover:dark:text-gray-200 hover:text-gray-800">{{ $secondary_text | markdownify | emojify }} <span aria-hidden="true">→</span></a>
{{end}}
</div>
</div>
</div>
</div>
+8 -8
View File
@@ -3,7 +3,7 @@
* Used for both SSR and client-side hydration
*/
import {Icon} from "../../shared/components/Icon.jsx";
import { Icon } from "../../shared/components/Icon.jsx";
// Simple markdown renderer
export function renderText(text) {
@@ -16,7 +16,7 @@ export function renderText(text) {
// Process URLs
export function processUrl(url) {
if (!url) return {href: "#"};
if (!url) return { href: "#" };
if (url.startsWith("http://") || url.startsWith("https://")) {
return {
@@ -27,18 +27,18 @@ export function processUrl(url) {
}
if (url.startsWith("#")) {
return {href: url};
return { href: url };
}
return {href: url};
return { href: url };
}
// Hero Block Component - Single implementation
export const HeroBlock = ({content, design, id, icon_svg}) => {
export const HeroBlock = ({ content, design, id, icon_svg }) => {
const paddingClasses = design?.no_padding ? "" : "py-32 sm:py-48 lg:py-56";
return (
<div class="relative isolate px-6 pt-14 lg:px-8" id={id}>
<div class="relative isolate px-6 pt-14 lg:px-8">
<div class={`mx-auto max-w-2xl ${paddingClasses}`}>
{/* Announcement Banner */}
{content.announcement?.text && (
@@ -73,7 +73,7 @@ export const HeroBlock = ({content, design, id, icon_svg}) => {
{content.title && (
<h1
class="text-4xl font-bold tracking-tight text-gray-900 dark:text-gray-100 sm:text-6xl"
dangerouslySetInnerHTML={{__html: renderText(content.title)}}
dangerouslySetInnerHTML={{ __html: renderText(content.title) }}
/>
)}
@@ -81,7 +81,7 @@ export const HeroBlock = ({content, design, id, icon_svg}) => {
{content.text && (
<p
class="mt-6 text-lg leading-8 text-gray-600 dark:text-gray-300 max-w-2xl mx-auto"
dangerouslySetInnerHTML={{__html: renderText(content.text)}}
dangerouslySetInnerHTML={{ __html: renderText(content.text) }}
/>
)}
@@ -1,12 +1,12 @@
// biome-ignore lint/correctness/noUnusedImports: Classic Preact JSX runtime may require 'h' for JSX transform
import {h} from "preact";
import { h } from "preact";
/**
* Icon component
* Renders an SVG icon from a raw SVG string passed from Hugo.
* Decodes JSON-escaped sequences (\u003c) and HTML entities (&lt;, &quot;, &#34;).
*/
export const Icon = ({svg, attributes}) => {
export const Icon = ({ svg, attributes }) => {
if (!svg) return null;
let decoded = String(svg)
@@ -28,8 +28,13 @@ export const Icon = ({svg, attributes}) => {
decoded = decoded.replace(/<svg\b/i, '<svg class="inline-block w-4 h-4"');
}
const wrapperProps = {
...attributes,
class: `inline-block ${attributes?.class || ""}`.trim(),
};
// eslint-disable-next-line lint/security/noDangerouslySetInnerHtml
return <span class="inline-block" dangerouslySetInnerHTML={{__html: decoded}} />;
return <span {...wrapperProps} dangerouslySetInnerHTML={{ __html: decoded }} />;
}
const finalAttributes = {
@@ -44,5 +49,5 @@ export const Icon = ({svg, attributes}) => {
.join(" ");
// eslint-disable-next-line lint/security/noDangerouslySetInnerHtml
return <span class="inline-block" dangerouslySetInnerHTML={{__html: `<svg ${attrs}>${decoded}</svg>`}} />;
return <span class="inline-block" dangerouslySetInnerHTML={{ __html: `<svg ${attrs}>${decoded}</svg>` }} />;
};
-341
View File
@@ -1,341 +0,0 @@
{{/* Hugo Blox: Stats */}}
{{/* Documentation: https://hugoblox.com/blocks/ */}}
{{/* License: https://github.com/HugoBlox/kit/blob/main/LICENSE.md */}}
{{/* Initialise */}}
{{ $page := .wcPage }}
{{ $block := .wcBlock }}
{{ $content := $block.content }}
{{ if not (reflect.IsMap $content) }}{{ $content = dict }}{{ end }}
{{ $design := $block.design }}
{{ if not (reflect.IsMap $design) }}{{ $design = dict }}{{ end }}
{{ $title := "" }}
{{ $title_raw := index $content "title" }}
{{ if $title_raw }}
{{ $title = strings.TrimSpace (printf "%v" $title_raw) }}
{{ end }}
{{ if $title }}{{ $title = $title | emojify | $page.RenderString }}{{ end }}
{{ $text := "" }}
{{ $text_raw := index $content "text" }}
{{ if $text_raw }}
{{ $text = strings.TrimSpace (printf "%v" $text_raw) }}
{{ end }}
{{ if $text }}{{ $text = $text | emojify | $page.RenderString }}{{ end }}
{{ $layout := "cards" }}
{{ $layout_raw := index $design "layout" }}
{{ if eq (printf "%T" $layout_raw) "string" }}
{{ $layout_candidate := lower (strings.TrimSpace $layout_raw) }}
{{ if in (slice "cards" "compact" "minimal") $layout_candidate }}
{{ $layout = $layout_candidate }}
{{ end }}
{{ end }}
{{ $items_raw := index $content "items" }}
{{ $items_iter := slice }}
{{ if reflect.IsSlice $items_raw }}
{{ $items_iter = $items_raw }}
{{ else if and $items_raw (reflect.IsMap $items_raw) }}
{{ $items_iter = slice $items_raw }}
{{ end }}
{{ $items := slice }}
{{ if gt (len $items_iter) 0 }}
{{ range $items_iter }}
{{ if reflect.IsMap . }}
{{ $icon := "" }}
{{ $statistic := "" }}
{{ $description := "" }}
{{ $sub_metric := "" }}
{{ with index . "icon" }}{{ $icon = printf "%v" . }}{{ end }}
{{ with index . "statistic" }}{{ $statistic = strings.TrimSpace (printf "%v" .) }}{{ end }}
{{ with index . "description" }}{{ $description = strings.TrimSpace (printf "%v" .) }}{{ end }}
{{ with index . "sub_metric" }}{{ $sub_metric = strings.TrimSpace (printf "%v" .) }}{{ end }}
{{ if or $statistic $description }}
{{ $items = $items | append (dict "icon" $icon "statistic" $statistic "description" $description "sub_metric" $sub_metric) }}
{{ end }}
{{ end }}
{{ end }}
{{ end }}
{{ $item_count := len $items }}
{{ if lt $item_count 1 }}{{ $item_count = 1 }}{{ end }}
{{ if gt $item_count 4 }}{{ $item_count = 4 }}{{ end }}
{{ $grid_cols_class := "lg:grid-cols-3" }}
{{ if eq $item_count 1 }}
{{ $grid_cols_class = "lg:grid-cols-1" }}
{{ else if eq $item_count 2 }}
{{ $grid_cols_class = "lg:grid-cols-2" }}
{{ else if eq $item_count 4 }}
{{ $grid_cols_class = "lg:grid-cols-4" }}
{{ end }}
{{ $base_cols_class := "grid-cols-2" }}
{{ if eq $item_count 1 }}{{ $base_cols_class = "grid-cols-1" }}{{ end }}
<div class="py-12 sm:py-16 lg:py-20">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
{{/* Section Header */}}
{{ if or $title $text }}
<div class="text-center mb-12 lg:mb-16">
{{ with $title }}
<h2 class="text-3xl sm:text-4xl font-bold text-gray-900 dark:text-white tracking-tight mb-4">
{{ . }}
</h2>
{{ end }}
{{ with $text }}
<p class="text-lg text-gray-600 dark:text-gray-400 max-w-3xl mx-auto">
{{ . }}
</p>
{{ end }}
</div>
{{ end }}
{{/* Cards Layout */}}
{{ if eq $layout "cards" }}
<div class="grid grid-cols-1 sm:grid-cols-2 {{ $grid_cols_class }} gap-6 lg:gap-8">
{{ range $idx, $item := $items }}
<div class="group relative bg-white dark:bg-gray-800 rounded-2xl shadow-lg hover:shadow-2xl transition-all duration-500 overflow-hidden border border-gray-100 dark:border-gray-700 hover:border-primary-200 dark:hover:border-primary-800">
{{/* Background Gradient */}}
<div class="absolute inset-0 bg-gradient-to-br from-primary-50 to-transparent dark:from-primary-900/20 dark:to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-500"></div>
<div class="relative p-8 text-center">
{{/* Icon */}}
{{ $icon := index $item "icon" }}
{{ if $icon }}
<div class="inline-flex items-center justify-center w-16 h-16 mb-6 bg-primary-100 dark:bg-primary-900/50 rounded-xl group-hover:scale-110 transition-transform duration-300">
{{ partial "functions/get_icon" (dict "name" $icon "attributes" "class=\"w-8 h-8 text-primary-600 dark:text-primary-400\"") }}
</div>
{{ end }}
{{/* Statistic */}}
<div class="mb-3">
{{ $statistic := index $item "statistic" }}
<h3 class="text-4xl sm:text-5xl lg:text-6xl xl:text-7xl font-black text-gray-900 dark:text-white group-hover:text-primary-600 dark:group-hover:text-primary-400 transition-colors duration-300 counter" data-target="{{ $statistic | replaceRE `[^0-9.]` "" }}">
{{ $statistic | $page.RenderString }}
</h3>
</div>
{{/* Description */}}
<p class="text-sm sm:text-base font-medium text-gray-600 dark:text-gray-300 leading-relaxed group-hover:text-gray-700 dark:group-hover:text-gray-200 transition-colors duration-300">
{{ (index $item "description") | $page.RenderString }}
</p>
{{/* Optional Sub-metric */}}
{{ with index $item "sub_metric" }}
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400">
{{ . }}
</p>
{{ end }}
</div>
</div>
{{ end }}
</div>
{{/* Compact Layout */}}
{{ else if eq $layout "compact" }}
<div class="bg-white dark:bg-gray-800 rounded-2xl shadow-xl border border-gray-100 dark:border-gray-700 overflow-hidden">
<div class="grid grid-cols-1 sm:grid-cols-2 {{ $grid_cols_class }} divide-y sm:divide-y-0 sm:divide-x divide-gray-200 dark:divide-gray-700">
{{ range $idx, $item := $items }}
<div class="group p-8 text-center hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors duration-300">
{{ $icon := index $item "icon" }}
{{ if $icon }}
<div class="inline-flex items-center justify-center w-12 h-12 mb-4 bg-primary-100 dark:bg-primary-900/50 rounded-lg group-hover:scale-110 transition-transform duration-300">
{{ partial "functions/get_icon" (dict "name" $icon "attributes" "class=\"w-6 h-6 text-primary-600 dark:text-primary-400\"") }}
</div>
{{ end }}
{{ $statistic := index $item "statistic" }}
<h3 class="text-3xl sm:text-4xl lg:text-5xl font-bold text-gray-900 dark:text-white mb-2 counter" data-target="{{ $statistic | replaceRE `[^0-9.]` "" }}">
{{ $statistic | $page.RenderString }}
</h3>
<p class="text-sm font-medium text-gray-600 dark:text-gray-300 leading-relaxed">
{{ (index $item "description") | $page.RenderString }}
</p>
</div>
{{ end }}
</div>
</div>
{{/* Minimal Layout */}}
{{ else }}
<div class="grid {{ $base_cols_class }} {{ $grid_cols_class }} gap-8 lg:gap-12">
{{ range $item := $items }}
<div class="text-center group">
{{ $icon := index $item "icon" }}
{{ if $icon }}
<div class="inline-flex items-center justify-center w-14 h-14 mb-4 bg-primary-100 dark:bg-primary-900/50 rounded-full group-hover:scale-110 transition-transform duration-300">
{{ partial "functions/get_icon" (dict "name" $icon "attributes" "class=\"w-7 h-7 text-primary-600 dark:text-primary-400\"") }}
</div>
{{ end }}
{{ $statistic := index $item "statistic" }}
<h3 class="text-3xl sm:text-4xl lg:text-5xl font-bold text-gray-900 dark:text-white mb-2 counter" data-target="{{ $statistic | replaceRE `[^0-9.]` "" }}">
{{ $statistic | $page.RenderString }}
</h3>
<p class="text-sm font-medium text-gray-600 dark:text-gray-300">
{{ (index $item "description") | $page.RenderString }}
</p>
</div>
{{ end }}
</div>
{{ end }}
</div>
</div>
{{/* Enhanced Animation CSS */}}
<style>
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(30px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes pulse-glow {
0%, 100% {
box-shadow: 0 0 0 0 rgb(59 130 246 / 0.4);
}
50% {
box-shadow: 0 0 0 10px rgb(59 130 246 / 0);
}
}
/* Intersection Observer Animation */
.stats-animate {
animation: fadeInUp 0.8s ease-out forwards;
}
.stats-item {
opacity: 0;
transform: translateY(30px);
transition: all 0.6s ease-out;
}
.stats-item.animate {
opacity: 1;
transform: translateY(0);
}
/* Stagger animation delays */
.stats-item:nth-child(1) { transition-delay: 0ms; }
.stats-item:nth-child(2) { transition-delay: 150ms; }
.stats-item:nth-child(3) { transition-delay: 300ms; }
.stats-item:nth-child(4) { transition-delay: 450ms; }
.stats-item:nth-child(5) { transition-delay: 600ms; }
/* Number counting animation */
@keyframes countUp {
from { opacity: 0; }
to { opacity: 1; }
}
.counter-animate {
animation: countUp 0.6s ease-out;
}
/* Hover glow effect */
.stats-card-glow:hover {
animation: pulse-glow 2s infinite;
}
</style>
{{/* Animation JavaScript for Counter and Intersection Observer */}}
<script>
document.addEventListener('DOMContentLoaded', function () {
const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
function parseTarget(counter) {
const dataTarget = counter.getAttribute('data-target');
const fallbackMatch = counter.textContent.match(/[0-9,.]+/);
const raw = (dataTarget ?? (fallbackMatch ? fallbackMatch[0] : '0')).replace(/,/g, '');
const num = Number(raw);
return isNaN(num) ? 0 : num;
}
function extractParts(text) {
const parts = text.match(/([^0-9]*)([0-9][0-9,.]*)(.*)/);
return {
prefix: parts ? parts[1] : '',
numberPart: parts ? parts[2] : '',
suffix: parts ? parts[3] : ''
};
}
function animateCountersFor(container) {
container.classList.add('animate');
const counters = container.querySelectorAll('.counter');
counters.forEach(counter => {
const target = parseTarget(counter);
if (target <= 0) return;
const { prefix, numberPart, suffix } = extractParts(counter.textContent);
const decimals = (numberPart.split('.')[1] || '').length;
const formatter = new Intl.NumberFormat(undefined, {
minimumFractionDigits: decimals,
maximumFractionDigits: decimals,
});
if (prefersReducedMotion) {
counter.textContent = `${prefix}${formatter.format(target)}${suffix}`;
return;
}
const duration = 2000; // 2 seconds
const start = Date.now();
function update() {
const now = Date.now();
const progress = Math.min((now - start) / duration, 1);
const easeOut = 1 - Math.pow(1 - progress, 3); // Ease-out cubic
const current = target * easeOut;
counter.textContent = `${prefix}${formatter.format(current)}${suffix}`;
if (progress < 1) requestAnimationFrame(update);
}
requestAnimationFrame(update);
});
}
function isInView(el) {
const rect = el.getBoundingClientRect();
const vh = window.innerHeight || document.documentElement.clientHeight;
const vw = window.innerWidth || document.documentElement.clientWidth;
return rect.top < vh * 0.9 && rect.bottom > 0 && rect.left < vw && rect.right > 0;
}
// Intersection Observer for stats animation (with near-fold sensitivity)
const statsObserver = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
animateCountersFor(entry.target);
statsObserver.unobserve(entry.target);
}
});
}, {
threshold: 0.2,
rootMargin: '0px 0px -10% 0px'
});
// Observe all stats containers (and trigger immediately if already visible)
const statsContainers = document.querySelectorAll('.stats-item, [class*="grid"] > div');
statsContainers.forEach(container => {
container.classList.add('stats-item');
if (isInView(container)) {
animateCountersFor(container);
} else {
statsObserver.observe(container);
}
});
});
</script>
+31
View File
@@ -0,0 +1,31 @@
/**
* Stats Block - Client-side Hydration
* Uses the shared StatsBlock component for consistency
*/
import { render } from "preact";
import { StatsBlock } from "./component.jsx";
function renderStatsBlocks() {
const statsBlocks = document.querySelectorAll('[data-block-type="stats"]');
statsBlocks.forEach((block) => {
const propsData = block.dataset.props;
if (propsData) {
try {
const props = JSON.parse(propsData);
render(<StatsBlock {...props} />, block);
console.debug(`✓ Stats block "${block.id}" rendered with Preact`);
} catch (error) {
console.error(`Failed to render Stats block "${block.id}":`, error);
}
}
});
if (statsBlocks.length > 0) {
console.debug(`✓ ${statsBlocks.length} Stats blocks initialized with Preact`);
}
}
// Initialize immediately — script is already deferred
renderStatsBlocks();
+300
View File
@@ -0,0 +1,300 @@
/**
* Stats Block Component - Single source of truth
* Used for both SSR (via preact-wrapper) and SaaS live preview
*
* Layouts: cards | compact | minimal
* Features: counter animation, per-item icons, stagger reveal
*/
import { useEffect, useRef } from "preact/hooks";
import { Icon } from "../../shared/components/Icon.jsx";
// Simple markdown renderer (shared with Hero)
function renderText(text) {
if (!text) return "";
return text
.replace(/\*\*(.*?)\*\*/g, "<strong>$1</strong>")
.replace(/\*(.*?)\*/g, "<em>$1</em>")
.replace(/`(.*?)`/g, "<code>$1</code>");
}
// Extract numeric target from a statistic string like "10,000+" → 10000
function parseTarget(statistic) {
if (!statistic) return 0;
const raw = String(statistic).replace(/[^0-9.]/g, "");
const num = Number(raw);
return isNaN(num) ? 0 : num;
}
// Extract prefix, number, suffix from "~10,000+" → { prefix: "~", number: "10,000", suffix: "+" }
function extractParts(text) {
if (!text) return { prefix: "", numberPart: "", suffix: "" };
const parts = String(text).match(/([^0-9]*)([0-9][0-9,.]*)(.*)/);
return {
prefix: parts ? parts[1] : "",
numberPart: parts ? parts[2] : "",
suffix: parts ? parts[3] : "",
};
}
// Counter animation hook
function useCounterAnimation(containerRef) {
useEffect(() => {
const container = containerRef.current;
if (!container) return;
const prefersReducedMotion = window.matchMedia(
"(prefers-reduced-motion: reduce)"
).matches;
function animateCounters(el) {
el.classList.add("animate");
const counters = el.querySelectorAll(".counter");
counters.forEach((counter) => {
const target = parseTarget(counter.getAttribute("data-target"));
if (target <= 0) return;
const { prefix, numberPart, suffix } = extractParts(
counter.textContent
);
const decimals = (numberPart.split(".")[1] || "").length;
const formatter = new Intl.NumberFormat(undefined, {
minimumFractionDigits: decimals,
maximumFractionDigits: decimals,
});
if (prefersReducedMotion) {
counter.textContent = `${prefix}${formatter.format(target)}${suffix}`;
return;
}
const duration = 2000;
const start = Date.now();
function update() {
const now = Date.now();
const progress = Math.min((now - start) / duration, 1);
const easeOut = 1 - Math.pow(1 - progress, 3);
const current = target * easeOut;
counter.textContent = `${prefix}${formatter.format(current)}${suffix}`;
if (progress < 1) requestAnimationFrame(update);
}
requestAnimationFrame(update);
});
}
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
animateCounters(entry.target);
observer.unobserve(entry.target);
}
});
},
{ threshold: 0.2, rootMargin: "0px 0px -10% 0px" }
);
// Observe each stat item
const items = container.querySelectorAll(".stats-item");
items.forEach((item) => {
const rect = item.getBoundingClientRect();
const vh = window.innerHeight;
if (rect.top < vh * 0.9 && rect.bottom > 0) {
animateCounters(item);
} else {
observer.observe(item);
}
});
return () => observer.disconnect();
}, []);
}
// Grid columns class based on item count
function gridColsClass(count) {
if (count <= 1) return "lg:grid-cols-1";
if (count === 2) return "lg:grid-cols-2";
if (count >= 4) return "lg:grid-cols-4";
return "lg:grid-cols-3";
}
// ─── Stat Item (Cards) ───────────────────────────────────────────────
function StatCard({ item, iconSvg }) {
return (
<div class="stats-item group relative bg-white dark:bg-gray-800 rounded-2xl shadow-lg hover:shadow-2xl transition-all duration-500 overflow-hidden border border-gray-100 dark:border-gray-700 hover:border-primary-200 dark:hover:border-primary-800">
{/* Hover gradient */}
<div class="absolute inset-0 bg-gradient-to-br from-primary-50 to-transparent dark:from-primary-900/20 dark:to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-500" />
<div class="relative p-8 text-center">
{/* Icon */}
{iconSvg && (
<div class="inline-flex items-center justify-center w-16 h-16 mb-6 bg-primary-100 dark:bg-primary-900/50 rounded-xl group-hover:scale-110 transition-transform duration-300">
<Icon svg={iconSvg} attributes={{ class: "w-8 h-8 text-primary-600 dark:text-primary-400" }} />
</div>
)}
{/* Statistic */}
<div class="mb-3">
<h3
class="text-4xl sm:text-5xl lg:text-6xl xl:text-7xl font-black text-gray-900 dark:text-white group-hover:text-primary-600 dark:group-hover:text-primary-400 transition-colors duration-300 counter"
data-target={parseTarget(item.statistic)}
dangerouslySetInnerHTML={{ __html: renderText(item.statistic) }}
/>
</div>
{/* Description */}
<p
class="text-sm sm:text-base font-medium text-gray-600 dark:text-gray-300 leading-relaxed group-hover:text-gray-700 dark:group-hover:text-gray-200 transition-colors duration-300"
dangerouslySetInnerHTML={{ __html: renderText(item.description) }}
/>
{/* Sub-metric */}
{item.sub_metric && (
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400">
{item.sub_metric}
</p>
)}
</div>
</div>
);
}
// ─── Stat Item (Compact) ─────────────────────────────────────────────
function StatCompact({ item, iconSvg }) {
return (
<div class="stats-item group p-8 text-center hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors duration-300">
{iconSvg && (
<div class="inline-flex items-center justify-center w-12 h-12 mb-4 bg-primary-100 dark:bg-primary-900/50 rounded-lg group-hover:scale-110 transition-transform duration-300">
<Icon svg={iconSvg} attributes={{ class: "w-6 h-6 text-primary-600 dark:text-primary-400" }} />
</div>
)}
<h3
class="text-3xl sm:text-4xl lg:text-5xl font-bold text-gray-900 dark:text-white mb-2 counter"
data-target={parseTarget(item.statistic)}
dangerouslySetInnerHTML={{ __html: renderText(item.statistic) }}
/>
<p
class="text-sm font-medium text-gray-600 dark:text-gray-300 leading-relaxed"
dangerouslySetInnerHTML={{ __html: renderText(item.description) }}
/>
</div>
);
}
// ─── Stat Item (Minimal) ─────────────────────────────────────────────
function StatMinimal({ item, iconSvg }) {
return (
<div class="stats-item text-center group">
{iconSvg && (
<div class="inline-flex items-center justify-center w-14 h-14 mb-4 bg-primary-100 dark:bg-primary-900/50 rounded-full group-hover:scale-110 transition-transform duration-300">
<Icon svg={iconSvg} attributes={{ class: "w-7 h-7 text-primary-600 dark:text-primary-400" }} />
</div>
)}
<h3
class="text-3xl sm:text-4xl lg:text-5xl font-bold text-gray-900 dark:text-white mb-2 counter"
data-target={parseTarget(item.statistic)}
dangerouslySetInnerHTML={{ __html: renderText(item.statistic) }}
/>
<p
class="text-sm font-medium text-gray-600 dark:text-gray-300"
dangerouslySetInnerHTML={{ __html: renderText(item.description) }}
/>
</div>
);
}
// ─── Main Stats Block ────────────────────────────────────────────────
export const StatsBlock = ({ content, design, id, icon_svgs }) => {
const containerRef = useRef(null);
useCounterAnimation(containerRef);
const title = content?.title;
const text = content?.text;
const items = Array.isArray(content?.items) ? content.items : [];
const layout = (design?.layout || "cards").toLowerCase();
const count = Math.max(1, Math.min(items.length, 4));
const lgCols = gridColsClass(count);
const baseCols = count === 1 ? "grid-cols-1" : "grid-cols-2";
// icon_svgs is a dict keyed by icon name, provided by preact-wrapper
const iconMap = icon_svgs || {};
return (
<div class="py-8 sm:py-12 lg:py-16" ref={containerRef}>
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
{/* Section Header */}
{(title || text) && (
<div class="text-center mb-12 lg:mb-16">
{title && (
<h2
class="text-3xl sm:text-4xl font-bold text-gray-900 dark:text-white tracking-tight mb-4"
dangerouslySetInnerHTML={{ __html: renderText(title) }}
/>
)}
{text && (
<p
class="text-lg text-gray-600 dark:text-gray-400 max-w-3xl mx-auto"
dangerouslySetInnerHTML={{ __html: renderText(text) }}
/>
)}
</div>
)}
{/* Cards Layout */}
{layout === "cards" && (
<div class={`grid grid-cols-1 sm:grid-cols-2 ${lgCols} gap-6 lg:gap-8`}>
{items.map((item, idx) => (
<StatCard key={idx} item={item} iconSvg={item.icon ? iconMap[item.icon] : null} />
))}
</div>
)}
{/* Compact Layout */}
{layout === "compact" && (
<div class="bg-white dark:bg-gray-800 rounded-2xl shadow-xl border border-gray-100 dark:border-gray-700 overflow-hidden">
<div class={`grid grid-cols-1 sm:grid-cols-2 ${lgCols} divide-y sm:divide-y-0 sm:divide-x divide-gray-200 dark:divide-gray-700`}>
{items.map((item, idx) => (
<StatCompact key={idx} item={item} iconSvg={item.icon ? iconMap[item.icon] : null} />
))}
</div>
</div>
)}
{/* Minimal Layout */}
{layout === "minimal" && (
<div class={`grid ${baseCols} ${lgCols} gap-8 lg:gap-12`}>
{items.map((item, idx) => (
<StatMinimal key={idx} item={item} iconSvg={item.icon ? iconMap[item.icon] : null} />
))}
</div>
)}
</div>
{/* Animation styles (scoped via container ref) */}
<style>{`
.stats-item {
opacity: 0;
transform: translateY(30px);
transition: opacity 0.6s ease-out, transform 0.6s ease-out;
}
.stats-item.animate {
opacity: 1;
transform: translateY(0);
}
.stats-item:nth-child(1) { transition-delay: 0ms; }
.stats-item:nth-child(2) { transition-delay: 150ms; }
.stats-item:nth-child(3) { transition-delay: 300ms; }
.stats-item:nth-child(4) { transition-delay: 450ms; }
.stats-item:nth-child(5) { transition-delay: 600ms; }
`}</style>
</div>
);
};
-148
View File
@@ -1,148 +0,0 @@
{{/* Hugo Blox: Testimonials */}}
{{/* Documentation: https://hugoblox.com/blocks/ */}}
{{/* License: https://github.com/HugoBlox/kit/blob/main/LICENSE.md */}}
{{/* Initialise */}}
{{ $page := .wcPage }}
{{ $block := .wcBlock }}
{{ $content := $block.content }}
{{ if not (reflect.IsMap $content) }}{{ $content = dict }}{{ end }}
{{ $title := "" }}
{{ $title_raw := index $content "title" }}
{{ if $title_raw }}
{{ $title = strings.TrimSpace (printf "%v" $title_raw) }}
{{ end }}
{{ if $title }}{{ $title = $title | emojify | $page.RenderString }}{{ end }}
{{ $text := "" }}
{{ $text_raw := index $content "text" }}
{{ if $text_raw }}
{{ $text = strings.TrimSpace (printf "%v" $text_raw) }}
{{ end }}
{{ if $text }}{{ $text = $text | emojify | $page.RenderString }}{{ end }}
{{ $items_raw := index $content "items" }}
{{ $items_iter := slice }}
{{ if reflect.IsSlice $items_raw }}
{{ $items_iter = $items_raw }}
{{ else if and $items_raw (reflect.IsMap $items_raw) }}
{{ $items_iter = slice $items_raw }}
{{ end }}
{{ $items := slice }}
{{ if gt (len $items_iter) 0 }}
{{ range $items_iter }}
{{ if reflect.IsMap . }}
{{ $item_text := "" }}
{{ $item_name := "" }}
{{ $item_role := "" }}
{{ $item_image := "" }}
{{ with index . "text" }}{{ $item_text = strings.TrimSpace (printf "%v" .) }}{{ end }}
{{ with index . "name" }}{{ $item_name = strings.TrimSpace (printf "%v" .) }}{{ end }}
{{ with index . "role" }}{{ $item_role = strings.TrimSpace (printf "%v" .) }}{{ end }}
{{ with index . "image" }}{{ $item_image = strings.TrimSpace (printf "%v" .) }}{{ end }}
{{ if or $item_text $item_name $item_role $item_image }}
{{ $items = $items | append (dict "text" $item_text "name" $item_name "role" $item_role "image" $item_image) }}
{{ end }}
{{ end }}
{{ end }}
{{ end }}
<section>
<div class="max-w-screen-xl px-4 py-8 mx-auto text-center lg:py-16 lg:px-6">
{{ with $title }}
<div class="py-8 px-4 mx-auto max-w-screen-xl sm:py-16 lg:px-6">
<div class="max-w-screen-md mb-8 lg:mb-16 flex flex-col justify-center mx-auto">
<h2 class="mb-4 text-4xl tracking-tight font-extrabold text-gray-900 dark:text-white text-center">{{.}}</h2>
{{ with $text }}
<p class="text-gray-500 sm:text-xl dark:text-gray-400 text-center">{{.}}</p>
{{ end }}
</div>
</div>
{{ end }}
{{ range $idx, $item := $items }}
<div class="max-w-screen-md mx-auto {{if ne (math.Add $idx 1) (len $items) }}mb-12{{end}}">
<svg class="h-16 mx-auto mb-3 text-gray-400 dark:text-gray-500" xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 32 32">
<path d="M9.563 8.469l-0.813-1.25c-5.625 3.781-8.75 8.375-8.75 12.156 0 3.656 2.688 5.375 4.969 5.375 2.875 0 4.906-2.438 4.906-5 0-2.156-1.375-4-3.219-4.688-0.531-0.188-1.031-0.344-1.031-1.25 0-1.156 0.844-2.875 3.938-5.344zM21.969 8.469l-0.813-1.25c-5.563 3.781-8.75 8.375-8.75 12.156 0 3.656 2.75 5.375 5.031 5.375 2.906 0 4.969-2.438 4.969-5 0-2.156-1.406-4-3.313-4.688-0.531-0.188-1-0.344-1-1.25 0-1.156 0.875-2.875 3.875-5.344z"/>
</svg>
<blockquote>
<p class="text-2xl font-medium text-gray-900 dark:text-white">"{{ (index $item "text") | $page.RenderString }}"</p>
</blockquote>
<div class="flex items-center justify-center mt-6 space-x-3">
{{/* Resolve avatar from page bundle, site assets, remote, or static URLs */}}
{{ $image_name := index $item "image" }}
{{/* Initialise as boolean to avoid nil command errors in pipeline */}}
{{ $image_resource := false }}
{{ $image_url := "" }}
{{ if $image_name }}
{{ $image_path := strings.TrimSpace (printf "%v" $image_name) }}
{{ $is_remote := or (strings.HasPrefix $image_path "http://") (strings.HasPrefix $image_path "https://") }}
{{ if $is_remote }}
{{ $remote := try (resources.GetRemote $image_path) }}
{{ if and $remote (not $remote.Err) }}
{{ $image_resource = $remote.Value }}
{{ else }}
{{/* If remote fetch fails, fall back to the original URL */}}
{{ $image_url = $image_path }}
{{ end }}
{{ else }}
{{/* Normalize common prefixes and dot paths */}}
{{ $image_path = strings.TrimPrefix "/" $image_path }}
{{ $image_path = strings.TrimPrefix "./" $image_path }}
{{ $image_path = strings.TrimPrefix "assets/" $image_path }}
{{ $image_path = strings.TrimPrefix "media/" $image_path }}
{{ $normalized := path.Clean $image_path }}
{{ if and $normalized (ne $normalized ".") }}
{{/* Prefer page bundle image resources */}}
{{ with $page }}
{{ $image_resource = (.Resources.ByType "image").GetMatch $normalized }}
{{ end }}
{{ if not $image_resource }}
{{/* Look in global assets/media - use resources.Get for exact path matching */}}
{{ $image_resource = resources.Get (path.Join "media" $normalized) }}
{{ end }}
{{ if not $image_resource }}
{{/* Static fallback (serves original size) */}}
{{ $image_url = printf "/media/%s" $normalized }}
{{ end }}
{{ end }}
{{ end }}
{{ end }}
{{ if $image_resource }}
{{ $avatar_96 := $image_resource.Process "Fill 96x96 Center webp" }}
<img class="w-12 h-12 rounded-full"
src="{{$avatar_96.RelPermalink}}"
width="48"
height="48"
alt="{{ (index $item "name") | plainify }}"
loading="lazy">
{{ else if $image_url }}
{{ $is_external := or (strings.HasPrefix $image_url "http://") (strings.HasPrefix $image_url "https://") }}
<img class="w-12 h-12 rounded-full"
src="{{ if $is_external }}{{ $image_url }}{{ else }}{{ $image_url | relURL }}{{ end }}"
width="48"
height="48"
alt="{{ (index $item "name") | plainify }}"
loading="lazy">
{{ else }}
<div class="w-12 h-12 rounded-full bg-gray-200 dark:bg-gray-700"></div>
{{ end }}
<div class="flex flex-col items-center">
<div class="pr-3 font-medium text-gray-900 dark:text-white">{{ index $item "name" }}</div>
<div class="pl-3 text-sm font-light text-gray-500 dark:text-gray-400">{{ index $item "role" }}</div>
</div>
</div>
</div>
{{ end }}
</div>
</section>
+25
View File
@@ -0,0 +1,25 @@
/**
* Testimonials Block - Client-side Hydration
*/
import { render } from "preact";
import { TestimonialsBlock } from "./component.jsx";
function renderTestimonialsBlocks() {
const blocks = document.querySelectorAll('[data-block-type="testimonials"]');
blocks.forEach((block) => {
const propsData = block.dataset.props;
if (propsData) {
try {
const props = JSON.parse(propsData);
render(<TestimonialsBlock {...props} />, block);
console.debug(`✓ Testimonials block "${block.id}" rendered with Preact`);
} catch (error) {
console.error(`Failed to render Testimonials block "${block.id}":`, error);
}
}
});
}
renderTestimonialsBlocks();
@@ -0,0 +1,108 @@
/**
* Testimonials Block Component - Single source of truth
* Quote cards with avatar, name, and role
* Avatars are pre-processed by Hugo's image pipeline (passed as item_images prop)
*/
// Quote mark SVG (inline for zero dependencies)
const QuoteSvg = () => (
<svg
class="h-16 mx-auto mb-3 text-gray-400 dark:text-gray-500"
xmlns="http://www.w3.org/2000/svg"
fill="currentColor"
viewBox="0 0 32 32"
>
<path d="M9.563 8.469l-0.813-1.25c-5.625 3.781-8.75 8.375-8.75 12.156 0 3.656 2.688 5.375 4.969 5.375 2.875 0 4.906-2.438 4.906-5 0-2.156-1.375-4-3.219-4.688-0.531-0.188-1.031-0.344-1.031-1.25 0-1.156 0.844-2.875 3.938-5.344zM21.969 8.469l-0.813-1.25c-5.563 3.781-8.75 8.375-8.75 12.156 0 3.656 2.75 5.375 5.031 5.375 2.906 0 4.969-2.438 4.969-5 0-2.156-1.406-4-3.313-4.688-0.531-0.188-1-0.344-1-1.25 0-1.156 0.875-2.875 3.875-5.344z" />
</svg>
);
// Simple markdown renderer
function renderText(text) {
if (!text) return "";
return String(text)
.replace(/\*\*(.*?)\*\*/g, "<strong>$1</strong>")
.replace(/\*(.*?)\*/g, "<em>$1</em>")
.replace(/`(.*?)`/g, "<code>$1</code>");
}
function TestimonialItem({ item, avatarUrl, isLast }) {
return (
<div class={`max-w-screen-md mx-auto ${isLast ? "" : "mb-12"}`}>
<QuoteSvg />
<blockquote>
<p
class="text-2xl font-medium text-gray-900 dark:text-white"
dangerouslySetInnerHTML={{ __html: `"${renderText(item.text)}"` }}
/>
</blockquote>
<div class="flex items-center justify-center mt-6 space-x-3">
{/* Avatar */}
{avatarUrl ? (
<img
class="w-12 h-12 rounded-full"
src={avatarUrl}
width="48"
height="48"
alt={item.name || ""}
loading="lazy"
/>
) : (
<div class="w-12 h-12 rounded-full bg-gray-200 dark:bg-gray-700" />
)}
<div class="flex flex-col items-center">
{item.name && (
<div class="pr-3 font-medium text-gray-900 dark:text-white">
{item.name}
</div>
)}
{item.role && (
<div class="pl-3 text-sm font-light text-gray-500 dark:text-gray-400">
{item.role}
</div>
)}
</div>
</div>
</div>
);
}
export const TestimonialsBlock = ({ content, design, id, item_images }) => {
const title = content?.title;
const text = content?.text;
const items = Array.isArray(content?.items) ? content.items : [];
const imageMap = item_images || {};
return (
<section>
<div class="max-w-screen-xl px-4 py-8 mx-auto text-center lg:py-16 lg:px-6">
{/* Section Header */}
{title && (
<div class="py-8 px-4 mx-auto max-w-screen-xl sm:py-16 lg:px-6">
<div class="max-w-screen-md mb-8 lg:mb-16 flex flex-col justify-center mx-auto">
<h2
class="mb-4 text-4xl tracking-tight font-extrabold text-gray-900 dark:text-white text-center"
dangerouslySetInnerHTML={{ __html: renderText(title) }}
/>
{text && (
<p
class="text-gray-500 sm:text-xl dark:text-gray-400 text-center"
dangerouslySetInnerHTML={{ __html: renderText(text) }}
/>
)}
</div>
</div>
)}
{/* Testimonial Items */}
{items.map((item, idx) => (
<TestimonialItem
key={idx}
item={item}
avatarUrl={imageMap[String(idx)]?.src || null}
isLast={idx === items.length - 1}
/>
))}
</div>
</section>
);
};
@@ -1,32 +0,0 @@
{{/* Hugo Blox: Hero Block - Preact Wrapper */}}
{{/* Single source of truth - Preact component handles everything */}}
{{/* Documentation: https://hugoblox.com/blocks/ */}}
{{/* License: https://github.com/HugoBlox/kit/blob/main/LICENSE.md */}}
{{/* Initialize variables */}}
{{ $page := .wcPage }}
{{ $block := .wcBlock }}
{{ $block_id := .wcIdentifier | default (printf "hero-%d" (now.Unix)) }}
{{/* Prepare props for the Preact component */}}
{{ $props := dict
"content" $block.content
"design" $block.design
"id" $block_id
}}
{{/* Convert props to JSON for client-side rendering */}}
{{ $propsJSON := $props | jsonify }}
{{/* Render a container that Preact will populate */}}
{{/* The component will be rendered immediately on page load */}}
<div
id="{{ $block_id }}"
class="hero-block-container"
data-props='{{ $propsJSON | safeHTML }}'
data-hero-render="immediate"
>
{{/* Empty - Preact renders everything */}}
</div>
{{/* Preact script loading is handled by libraries.html */}}
@@ -17,8 +17,7 @@
"type" $block_type
}}
{{/* If an icon is specified, fetch its SVG data and add it to the props */}}
{{/* TODO: Generalize this to work with all block icon fields */}}
{{/* Resolve icon for primary_action (hero, cta blocks) */}}
{{ with $block.content.primary_action.icon }}
{{ $icon_data := partial "functions/get_icon_data" (dict "name" .) }}
{{ if $icon_data }}
@@ -26,12 +25,129 @@
{{ end }}
{{ end }}
{{/* Resolve icon for single button (cta-card) */}}
{{ with $block.content.button.icon }}
{{ $icon_data := partial "functions/get_icon_data" (dict "name" .) }}
{{ if $icon_data }}
{{ $props = merge $props (dict "button_icon_svg" $icon_data) }}
{{ end }}
{{ end }}
{{/* Resolve per-item icons (stats, features, etc.) into an icon_svgs map */}}
{{ $icon_svgs := dict }}
{{ with $block.content.items }}
{{ range . }}
{{ with .icon }}
{{ if not (index $icon_svgs .) }}
{{ $icon_data := partial "functions/get_icon_data" (dict "name" .) }}
{{ if $icon_data }}
{{ $icon_svgs = merge $icon_svgs (dict . $icon_data) }}
{{ end }}
{{ end }}
{{ end }}
{{ end }}
{{ end }}
{{ if gt (len $icon_svgs) 0 }}
{{ $props = merge $props (dict "icon_svgs" $icon_svgs) }}
{{ end }}
{{/* Resolve per-item images via Hugo's image pipeline */}}
{{/* Thumbnail mode (testimonials): Fill 96x96 webp */}}
{{/* Responsive mode (cta-image-paragraph): srcset with breakpoints */}}
{{ $item_images := dict }}
{{ with $block.content.items }}
{{ range $idx, $item := . }}
{{ with .image }}
{{ $image_path := strings.TrimSpace (printf "%v" .) }}
{{ $is_remote := or (strings.HasPrefix $image_path "http://") (strings.HasPrefix $image_path "https://") }}
{{/* Resolve the image resource */}}
{{ $image_resource := false }}
{{ $fallback_url := "" }}
{{ if $is_remote }}
{{ $remote := try (resources.GetRemote $image_path) }}
{{ if and $remote (not $remote.Err) }}
{{ $image_resource = $remote.Value }}
{{ else }}
{{ $fallback_url = $image_path }}
{{ end }}
{{ else }}
{{ $normalized := $image_path }}
{{ $normalized = strings.TrimPrefix "/" $normalized }}
{{ $normalized = strings.TrimPrefix "./" $normalized }}
{{ $normalized = strings.TrimPrefix "assets/" $normalized }}
{{ $normalized = strings.TrimPrefix "media/" $normalized }}
{{ $normalized = path.Clean $normalized }}
{{ if and $normalized (ne $normalized ".") }}
{{ with $page }}
{{ $image_resource = (.Resources.ByType "image").GetMatch $normalized }}
{{ end }}
{{ if not $image_resource }}
{{ $image_resource = resources.Get (path.Join "media" $normalized) }}
{{ end }}
{{ if not $image_resource }}
{{ $fallback_url = printf "/media/%s" $normalized }}
{{ end }}
{{ end }}
{{ end }}
{{ $img_data := dict }}
{{ if $image_resource }}
{{ if eq $block_type "cta-image-paragraph" }}
{{/* Responsive mode: generate srcset via process_responsive_image */}}
{{ if ne $image_resource.MediaType.SubType "gif" }}
{{ $responsive := partial "functions/process_responsive_image.html" (dict
"image" $image_resource
"mode" "fit"
"sizes" (slice 400 600 800 1200)
) }}
{{ $img_data = dict "srcset" $responsive.srcset "src" $responsive.fallback.RelPermalink "width" $responsive.fallback.Width "height" $responsive.fallback.Height }}
{{ else }}
{{ $img_data = dict "src" $image_resource.RelPermalink "width" $image_resource.Width "height" $image_resource.Height }}
{{ end }}
{{ else }}
{{/* Thumbnail mode (testimonials, etc.): Fill 96x96 */}}
{{ $processed := $image_resource.Process "Fill 96x96 Center webp" }}
{{ $img_data = dict "src" $processed.RelPermalink }}
{{ end }}
{{ else if $fallback_url }}
{{ $img_data = dict "src" $fallback_url }}
{{ end }}
{{ if $img_data }}
{{ $item_images = merge $item_images (dict (printf "%d" $idx) $img_data) }}
{{ end }}
{{ end }}
{{ end }}
{{ end }}
{{ if gt (len $item_images) 0 }}
{{ $props = merge $props (dict "item_images" $item_images) }}
{{ end }}
{{/* Resolve per-item feature_icon (cta-image-paragraph) */}}
{{ with $block.content.items }}
{{ range . }}
{{ with .feature_icon }}
{{ if not (index $icon_svgs .) }}
{{ $icon_data := partial "functions/get_icon_data" (dict "name" .) }}
{{ if $icon_data }}
{{ $icon_svgs = merge $icon_svgs (dict . $icon_data) }}
{{ end }}
{{ end }}
{{ end }}
{{ end }}
{{ end }}
{{ if gt (len $icon_svgs) 0 }}
{{ $props = merge $props (dict "icon_svgs" $icon_svgs) }}
{{ end }}
{{/* Convert props to JSON for client-side rendering */}}
{{ $propsJSON := $props | jsonify }}
{{/* Render a container that Preact will populate */}}
<div
id="{{ $block_id }}"
class="{{ $block_type }}-block-container preact-block"
data-block-type="{{ $block_type }}"
data-props='{{ $propsJSON | safeHTML }}'
@@ -1,11 +1,15 @@
{{ $branding := partialCached "functions/get_branding" . "branding" }}
{{ $logo := partialCached "functions/get_logo" (dict "constraint" "max_height" "size" 36) "navbar_logo" }}
{{- $show_logo := site.Params.hugoblox.header.show_logo | default true -}}
{{- $show_title := site.Params.hugoblox.header.show_title | default true -}}
{{- $navbar_title := site.Params.hugoblox.header.title | default $branding.name -}}
<header id="site-header" class="header">
<nav class="navbar px-3 flex justify-start">
<div class="order-0 h-full">
<!-- Brand -->
<a class="navbar-brand" href="{{ site.Home.RelPermalink }}" title="{{ $branding.site_title }}">
{{/* Auto-detect logo from assets/media/logo.{svg,png} */}}
{{ if $show_logo }}
{{ with $logo.resource }}
{{ if $logo.is_svg }}
{{- .Content | safeHTML -}}
@@ -20,8 +24,9 @@
alt="{{ $branding.name }}" />
{{ end }}
{{ end }}
{{ end }}
{{/* Display brand name */}}
{{ $branding.name }}
{{ if $show_title }}{{ $navbar_title }}{{ end }}
</a>
</div>
<!-- Mobile menu -->
@@ -177,7 +177,7 @@
{{ if ne $bg_img.MediaType.SubType "svg" }}
{{ $bg_img = $bg_img.Fit "1920x1920 webp" }}
{{ end }}
{{ $image_layer := printf "url('%s')" $bg_img.Permalink }}
{{ $image_layer := printf "url('%s')" $bg_img.RelPermalink }}
{{ $background_layers = $background_layers | append $image_layer }}
{{ end }}
{{ end }}
@@ -27,32 +27,55 @@ and Tailwind's real filesystem scanner.
{{ $paths = $paths | append `assets/js/preact-built/**/*.js` }}
{{/*
3. Inline sources: extract class attributes from Preact JSX/TSX and embed them
directly so Tailwind can always see them regardless of FS layout.
3. Comprehensive class extraction from Preact JSX/TSX source files.
Hugo's virtual filesystem mounts block JSX/TSX into assets/js/hbx/blocks/.
Tailwind's @source file paths only work on the real filesystem, but these
files exist only in Hugo's virtual FS (or in a module cache for remote modules).
So we read the file content via resources.Match and extract class candidates inline.
Strategy: Extract ALL string literal content — not just class= attributes.
This catches classes in any pattern:
- Static attrs: class="gap-8 items-center"
- JSX expressions: class={"gap-8 items-center"}
- Template literals: class={`gap-8 ${cond ? "md:grid" : ""}`}
- Utility fns: cn("gap-8", condition && "md:grid-cols-2")
Tailwind's scanner automatically ignores non-class tokens (import paths,
error messages, etc.), so false positives are harmless.
*/}}
{{ $jsx_files := resources.Match "js/hbx/**/*.jsx" }}
{{ $tsx_files := resources.Match "js/hbx/**/*.tsx" }}
{{ $all_jsx := resources.Match "js/hbx/**/*.jsx" }}
{{ $all_tsx := resources.Match "js/hbx/**/*.tsx" }}
{{ $extracted_classes := slice }}
{{ range $f := $jsx_files }}
{{ range $f := $all_jsx | append $all_tsx }}
{{ $code := $f.Content }}
{{ $matches := findRE `class(?:Name)?\s*=\s*"([^"]+)"` $code }}
{{ range $m := $matches }}
{{ $cls := replaceRE `^class(?:Name)?\s*=\s*"([^"]+)"$` `$1` $m }}
{{ $classes := split $cls " " }}
{{ range $class := $classes }}
{{ $extracted_classes = $extracted_classes | append $class }}
{{/* A) Extract content of all double-quoted strings */}}
{{ $dq_matches := findRE `"[^"]*"` $code }}
{{ range $m := $dq_matches }}
{{ $str := strings.TrimPrefix `"` (strings.TrimSuffix `"` $m) }}
{{ range split $str " " }}
{{ $t := strings.TrimSpace . }}
{{/* Filter out tokens with unsafe characters (braces, quotes, parens) */}}
{{ if and $t (not (findRE `[\{\}\(\)"';]` $t)) }}
{{ $extracted_classes = $extracted_classes | append $t }}
{{ end }}
{{ end }}
{{ end }}
{{ end }}
{{ range $f := $tsx_files }}
{{ $code := $f.Content }}
{{ $matches := findRE `class(?:Name)?\s*=\s*"([^"]+)"` $code }}
{{ range $m := $matches }}
{{ $cls := replaceRE `^class(?:Name)?\s*=\s*"([^"]+)"$` `$1` $m }}
{{ $classes := split $cls " " }}
{{ range $class := $classes }}
{{ $extracted_classes = $extracted_classes | append $class }}
{{/* B) Extract static parts of template literals (backtick strings) */}}
{{/* Removes ${...} interpolations, keeping surrounding text */}}
{{ $bt_matches := findRE "`[^`]*`" $code }}
{{ range $m := $bt_matches }}
{{ $str := strings.TrimPrefix "`" (strings.TrimSuffix "`" $m) }}
{{ $static := replaceRE `\$\{[^}]*\}` " " $str }}
{{ range split $static " " }}
{{ $t := strings.TrimSpace . }}
{{/* Filter out tokens with unsafe characters (braces, quotes, parens) */}}
{{ if and $t (not (findRE `[\{\}\(\)"';]` $t)) }}
{{ $extracted_classes = $extracted_classes | append $t }}
{{ end }}
{{ end }}
{{ end }}
{{ end }}
+2 -1
View File
@@ -32,6 +32,7 @@
"scripts": {
"prod": "scripts/view-starter-prod.sh",
"dev": "scripts/view-starter-dev.sh",
"dev:stop": "lsof -ti :8081 | xargs kill -9",
"test": "scripts/view-test.sh",
"lint": "biome check --reporter=summary .",
"lint:code": "biome check --reporter=summary . --linter-enabled=true --formatter-enabled=false",
@@ -46,4 +47,4 @@
"test:releaser": "poetry run python scripts/test_release_modules.py"
},
"browserslist": "> 1%"
}
}
@@ -13,11 +13,11 @@ hugoblox:
# ────────────────────────────────────────────────────────────────────────────
identity:
# Site name - shown in navbar, footer, copyright, and browser title by default
name: "Your Name"
name: "Company Name"
# Legal entity for copyright notices (defaults to name if not set)
organization: ""
# Schema.org type for structured data
type: person # person | organization | local_business
type: organization # person | organization | local_business
# Short tagline displayed on your site
tagline: ""
# Site meta description for search engines and social sharing
@@ -83,9 +83,15 @@ hugoblox:
header:
enable: true
# Header style variant
style: navbar # navbar | navbar-simple | minimal
block: navbar # navbar | navbar-simple
# Stick to top on scroll
sticky: true
# Show logo in navbar
show_logo: false
# Show site title text in navbar
show_title: true
# Override navbar title (defaults to identity.name)
title: ""
# Menu alignment
align: center # left | center | right
# Feature toggles
@@ -25,9 +25,9 @@ sections:
text: "Read more"
url: "/blog/"
design:
# spacing:
# padding: [0, 0, 0, 0]
# margin: [0, 0, 0, 0]
spacing:
padding: [0, 0, 0, 0]
margin: [0, 0, 0, 0]
# For full-screen, add `min-h-screen` below
css_class: "dark"
background: