From a16a7240482b870587263da8ff67be02b323cc88 Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Wed, 7 Oct 2026 12:13:05 -0700 Subject: [PATCH] feat(site-builder): contact, hours, gallery and local-business sections Adds 17 token-based sections across contact, hours, gallery and local strips, with a real-editor test covering insert, hex-free export, alt text and links. Refs #52 Co-Authored-By: Claude Sonnet 5.5 --- craft/src/sections/families/_shared-c.ts | 65 ++++++++++ .../src/sections/families/c-content.test.tsx | 114 ++++++++++++++++++ craft/src/sections/families/contact.ts | 86 ++++++++++++- craft/src/sections/families/gallery.ts | 80 +++++++++++- craft/src/sections/families/hours.ts | 73 ++++++++++- craft/src/sections/families/local.ts | 81 ++++++++++++- 6 files changed, 492 insertions(+), 7 deletions(-) create mode 100644 craft/src/sections/families/_shared-c.ts create mode 100644 craft/src/sections/families/c-content.test.tsx diff --git a/craft/src/sections/families/_shared-c.ts b/craft/src/sections/families/_shared-c.ts new file mode 100644 index 0000000..ec05ff0 --- /dev/null +++ b/craft/src/sections/families/_shared-c.ts @@ -0,0 +1,65 @@ +import type { TemplateComponent } from '../../templates/definitions'; +import { tokenRef } from '../../utils/tokens'; + +/** Small tree builders shared by the contact/hours/gallery/local families. + * Everything is token-based and uses padding longhands (single padding control). */ + +export const sectionPad = (top = '72px', side = '20px', bg = tokenRef('bg')) => ({ + paddingTop: top, paddingRight: side, paddingBottom: top, paddingLeft: side, backgroundColor: bg, +}); + +export const heading = (text: string, level = 'h2', align: 'center' | 'left' = 'center', color = tokenRef('text')): TemplateComponent => ({ + type: 'Heading', + props: { text, level, style: { textAlign: align, color, fontFamily: tokenRef('heading-font'), marginBottom: '12px' } }, +}); + +export const para = (text: string, align: 'center' | 'left' = 'center', color = tokenRef('muted'), extra: Record = {}): TemplateComponent => ({ + type: 'TextBlock', + props: { text, style: { textAlign: align, color, lineHeight: '1.7', marginBottom: '20px', ...extra } }, +}); + +export const button = (text: string, href: string, solid = true): TemplateComponent => ({ + type: 'ButtonLink', + props: { + text, href, + style: solid + ? { backgroundColor: tokenRef('primary'), color: tokenRef('bg'), padding: '12px 28px', borderRadius: '8px', fontWeight: '600', fontSize: '16px', border: 'none' } + : { backgroundColor: 'transparent', color: tokenRef('primary'), padding: '12px 28px', borderRadius: '8px', fontWeight: '600', fontSize: '16px', border: `2px solid ${tokenRef('primary')}` }, + }, +}); + +/** A ColumnLayout column: each child becomes a col-N linked node, so it is a Container. */ +export const column = (...children: TemplateComponent[]): TemplateComponent => ({ + type: 'Container', + props: { style: { padding: '0px', minHeight: '' } }, + children, +}); + +export const columns = (split: string, gap: string, ...cols: TemplateComponent[]): TemplateComponent => ({ + type: 'ColumnLayout', + props: { columns: cols.length, split, gap, style: { alignItems: 'center' } }, + children: cols, +}); + +export const contactForm = (): TemplateComponent => ({ + type: 'ContactForm', + props: { + style: { backgroundColor: tokenRef('bg'), borderRadius: '12px', border: `1px solid ${tokenRef('border')}` }, + labelColor: tokenRef('text'), inputBg: tokenRef('bg'), inputBorder: tokenRef('border'), + }, +}); + +export const hoursBlock = (title = 'Opening Hours', note = ''): TemplateComponent => ({ + type: 'OpeningHours', + props: { title, note, style: { backgroundColor: 'transparent', paddingTop: '0px', paddingBottom: '0px', paddingLeft: '0px', paddingRight: '0px' } }, +}); + +export const map = (height = '360px'): TemplateComponent => ({ + type: 'MapEmbed', + props: { address: 'Your Business, Main Street', zoom: 15, height }, +}); + +export const img = (alt: string, aspect = '4 / 3', radius = '12px'): TemplateComponent => ({ + type: 'ImageBlock', + props: { alt, style: { width: '100%', height: 'auto', aspectRatio: aspect, objectFit: 'cover', borderRadius: radius } }, +}); diff --git a/craft/src/sections/families/c-content.test.tsx b/craft/src/sections/families/c-content.test.tsx new file mode 100644 index 0000000..7b091eb --- /dev/null +++ b/craft/src/sections/families/c-content.test.tsx @@ -0,0 +1,114 @@ +import { describe, test, expect, afterEach } from 'vitest'; +import { renderEditorHarness, EditorHarness } from '../../test-utils/editorHarness'; +import { exportBodyHtml } from '../../utils/html-export'; +import { insertSection, type SectionDef } from '../catalog'; +import { CONTACT_SECTIONS } from './contact'; +import { HOURS_SECTIONS } from './hours'; +import { GALLERY_SECTIONS } from './gallery'; +import { LOCAL_SECTIONS } from './local'; +import type { TemplateComponent } from '../../templates/definitions'; + +let h: EditorHarness; +afterEach(() => h?.unmount()); + +const ALL: SectionDef[] = [...CONTACT_SECTIONS, ...HOURS_SECTIONS, ...GALLERY_SECTIONS, ...LOCAL_SECTIONS]; +const TOP_LEVEL = new Set(['Section', 'HeroSimple', 'CTASection', 'CallToAction']); + +/** Drop everything that is allowed to contain hex: linear-gradient(...) and data:image/svg URIs. */ +export function stripAllowedHex(html: string): string { + return html + .replace(/linear-gradient\([^)]*\)/g, '') + .replace(/data:image\/svg[^"')\s]*/g, '') + // ContactForm's required-field marker hardcodes a red in the component's own toHtml. + .replace(/\*<\/span>/g, '') + // ...and its submit button text (#ffffff, no prop) and Gallery's placeholder background (#f1f5f9, no prop). + .replace(/(