Compare commits
143
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8bf525c600 | ||
|
|
1d9460c173 | ||
|
|
8d69982a0b | ||
|
|
fb40e3ece4 | ||
|
|
9591fcb8a7 | ||
|
|
f80811318b | ||
|
|
cb9fea6656 | ||
|
|
b1afdcb585 | ||
|
|
f00e4db3dc | ||
|
|
b9552faa30 | ||
|
|
77f35c4e9e | ||
|
|
2c8425ffb0 | ||
|
|
2a071bc7ab | ||
|
|
2a8a26687b | ||
|
|
979331b12d | ||
|
|
329a782052 | ||
|
|
da558fd52d | ||
|
|
8aeadefa88 | ||
|
|
2066059c35 | ||
|
|
473fe8d421 | ||
|
|
5d6ae7946b | ||
|
|
1c85ab93eb | ||
|
|
67228f24b4 | ||
|
|
613a44c4d4 | ||
|
|
5685670895 | ||
|
|
72f85a97e5 | ||
|
|
ab28ad8f2c | ||
|
|
05e00c572d | ||
|
|
458069afb6 | ||
|
|
b3e5009aec | ||
|
|
eeb0660d83 | ||
|
|
138e1a8273 | ||
|
|
1b12b79a0d | ||
|
|
621bb21d52 | ||
|
|
3f3c6fb851 | ||
|
|
802938ec1a | ||
|
|
3e43aee6e9 | ||
|
|
bf4a9f48eb | ||
|
|
86455413d0 | ||
|
|
0cbc58f8d1 | ||
|
|
92841e3f35 | ||
|
|
e892ee0e53 | ||
|
|
591a51dcc2 | ||
|
|
7ba91d9829 | ||
|
|
e12fb89ada | ||
|
|
25507cb57a | ||
|
|
9d3bbc2c46 | ||
|
|
25e674badd | ||
|
|
e5f30a4a56 | ||
|
|
36c3b2f503 | ||
|
|
5acf172511 | ||
|
|
ebb8fe1027 | ||
|
|
b88b242b6a | ||
|
|
8f51b5144a | ||
|
|
4733fe84b5 | ||
|
|
3cd4ad0154 | ||
|
|
6421306849 | ||
|
|
e40d55b2f2 | ||
|
|
1958b8e0b5 | ||
|
|
99cc4c79f2 | ||
|
|
4b8dd8baee | ||
|
|
46ebd253f3 | ||
|
|
a036843728 | ||
|
|
177eda93d0 | ||
|
|
8029126ab7 | ||
|
|
c2aac870e7 | ||
|
|
9969adca72 | ||
|
|
9b532e36e8 | ||
|
|
c83db99ae4 | ||
|
|
605a6ba9f3 | ||
|
|
f3c175436a | ||
|
|
b2b9278d3c | ||
|
|
71e675489c | ||
|
|
fdd088b4bf | ||
|
|
cdcc3969bc | ||
|
|
2887cc9bdf | ||
|
|
a0cdbd1d82 | ||
|
|
f0728a9070 | ||
|
|
36ce256760 | ||
|
|
cf56f2a388 | ||
|
|
e1d381dc8a | ||
|
|
d4ee09e54c | ||
|
|
7973ee9ba8 | ||
|
|
2ee66ebae9 | ||
|
|
a7c00119e6 | ||
|
|
232dc3c980 | ||
|
|
65a10a1ef9 | ||
|
|
4674a99ec9 | ||
|
|
c50b385661 | ||
|
|
296c10d019 | ||
|
|
b46b6915a5 | ||
|
|
4b36ce0d6a | ||
|
|
1a88baa95d | ||
|
|
97123c4c58 | ||
|
|
4c001e1af4 | ||
|
|
fb4e9f87be | ||
|
|
7179287087 | ||
|
|
48d0441be3 | ||
|
|
fad1882117 | ||
|
|
cce984508f | ||
|
|
94140990c2 | ||
|
|
02e99f7623 | ||
|
|
aaa305cc3e | ||
|
|
97cb439508 | ||
|
|
d20b77e66d | ||
|
|
5a26e4ef43 | ||
|
|
1cfb51f181 | ||
|
|
814ad29b91 | ||
|
|
b9c5d3dd1c | ||
|
|
6b9c258d26 | ||
|
|
4877a63a3b | ||
|
|
cf5d30382a | ||
|
|
66117d375e | ||
|
|
53c40f856f | ||
|
|
d0925d9e2d | ||
|
|
7b747f775f | ||
|
|
330032eea3 | ||
|
|
5e60415311 | ||
|
|
87dd4340f7 | ||
|
|
a1ec51afc3 | ||
|
|
43627bddb0 | ||
|
|
849f432330 | ||
|
|
6428f93cec | ||
|
|
906695379b | ||
|
|
069ea1235a | ||
|
|
ac0347ae5f | ||
|
|
5c5066c20b | ||
|
|
0f943bacc7 | ||
|
|
2ca1ff0cf9 | ||
|
|
e651becdbe | ||
|
|
b4d71340e1 | ||
|
|
bf55ee85b9 | ||
|
|
cf3457aa15 | ||
|
|
f6243d3ffe | ||
|
|
8d094a9c67 | ||
|
|
14a957f57c | ||
|
|
bd15a33984 | ||
|
|
606c9b78c8 | ||
|
|
8eeaecd857 | ||
|
|
c2bacb41bf | ||
|
|
1558626b84 | ||
|
|
4acbeefaed | ||
|
|
91a6b6f34b |
@@ -1,5 +1,11 @@
|
||||
# Site Builder - Project Documentation
|
||||
|
||||
> **LEGACY:** This is the original GrapesJS-based site builder. It has been superseded by the
|
||||
> Craft.js rebuild located at `/workspace/site-builder/craft/`. All new development happens there.
|
||||
> This file is preserved as historical reference for the GrapesJS architecture.
|
||||
>
|
||||
> The active version: **`/workspace/site-builder/craft/CLAUDE.md`**
|
||||
|
||||
## Overview
|
||||
|
||||
A visual drag-and-drop website builder using GrapesJS. Users can create multi-page websites without writing code, with server-side file storage for assets and localStorage persistence for editor state.
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.playwright-mcp/
|
||||
test-results/
|
||||
playwright-report/
|
||||
*.tsbuildinfo
|
||||
+475
@@ -0,0 +1,475 @@
|
||||
# WHP Site Builder v2 (Craft.js) - Project Documentation
|
||||
|
||||
## Overview
|
||||
|
||||
A visual drag-and-drop website builder rebuilt from the ground up using Craft.js, React 18, and TypeScript. Replaces the legacy GrapesJS-based editor (`/workspace/site-builder/`). Users create multi-page websites without writing code, with server-side storage through WHP's PHP API layer.
|
||||
|
||||
**Stack:** Vite 6 + React 18 + TypeScript 5 + @craftjs/core 0.2.x
|
||||
**Bundle:** ~460KB JS + ~15KB CSS
|
||||
**Version:** 2.0.0
|
||||
|
||||
## File Structure
|
||||
|
||||
```
|
||||
craft/
|
||||
├── index.html # HTML shell (loads fonts, FA icons, mounts React)
|
||||
├── package.json # Dependencies and scripts (v2.0.0)
|
||||
├── tsconfig.json # TypeScript config (ES2020, strict, path aliases)
|
||||
├── vite.config.ts # Vite config (builds to dist/js/editor.js + dist/css/editor.css)
|
||||
├── CLAUDE.md # This file
|
||||
├── README.md # Brief project readme
|
||||
├── FEATURES.md # User-facing features list
|
||||
├── dist/ # Build output (not committed)
|
||||
│ ├── index.html
|
||||
│ ├── js/editor.js
|
||||
│ ├── css/editor.css
|
||||
│ └── assets/
|
||||
├── src/
|
||||
│ ├── main.tsx # Entry point: reads WHP_CONFIG, mounts <App>
|
||||
│ ├── App.tsx # Wraps <Editor> with providers, passes resolver
|
||||
│ │
|
||||
│ ├── types/
|
||||
│ │ └── index.ts # WhpConfig, PageData, AssetData, StyleProps, DeviceMode
|
||||
│ │
|
||||
│ ├── state/
|
||||
│ │ ├── EditorConfigContext.tsx # React context for WHP_CONFIG (useEditorConfig hook)
|
||||
│ │ ├── PageContext.tsx # Multi-page state (pages, header, footer, CRUD, switching)
|
||||
│ │ └── SiteDesignContext.tsx # Site-wide design tokens (17 properties, Basic/Advanced)
|
||||
│ │
|
||||
│ ├── editor/
|
||||
│ │ ├── EditorShell.tsx # 3-panel layout: TopBar + LeftPanel + Canvas + RightPanel + ContextMenu
|
||||
│ │ └── Canvas.tsx # Craft.js <Frame> with device-width switching
|
||||
│ │
|
||||
│ ├── components/
|
||||
│ │ ├── resolver.ts # Component map for Craft.js serialization (39 components)
|
||||
│ │ ├── layout/
|
||||
│ │ │ ├── Container.tsx # Generic container (div/section/article/header/footer/main)
|
||||
│ │ │ ├── Section.tsx # Full-width section with centered inner container
|
||||
│ │ │ ├── ColumnLayout.tsx # Flex columns (1-6, with split ratios)
|
||||
│ │ │ └── BackgroundSection.tsx # Section with background image/gradient overlay
|
||||
│ │ ├── basic/
|
||||
│ │ │ ├── Heading.tsx # Inline-editable heading (h1-h6)
|
||||
│ │ │ ├── TextBlock.tsx # Inline-editable paragraph
|
||||
│ │ │ ├── ButtonLink.tsx # Styled <a> with color presets
|
||||
│ │ │ ├── Navbar.tsx # Navigation bar (text/image logo, page links, external links, CTA)
|
||||
│ │ │ ├── Footer.tsx # Footer component (links, copyright, social)
|
||||
│ │ │ ├── Divider.tsx # Horizontal rule (color, thickness)
|
||||
│ │ │ └── Spacer.tsx # Vertical spacing element
|
||||
│ │ ├── media/
|
||||
│ │ │ ├── ImageBlock.tsx # Image with placeholder, upload, browse, sizing
|
||||
│ │ │ └── VideoBlock.tsx # Video embed (YouTube, Vimeo, direct files, background mode)
|
||||
│ │ ├── sections/
|
||||
│ │ │ ├── HeroSimple.tsx # Pre-built hero section with heading, subtext, CTA
|
||||
│ │ │ ├── FeaturesGrid.tsx # 3-column feature cards grid
|
||||
│ │ │ └── CTASection.tsx # Call-to-action banner section
|
||||
│ │ └── forms/
|
||||
│ │ ├── FormContainer.tsx # Form wrapper with action/method
|
||||
│ │ ├── InputField.tsx # Input field with label and placeholder
|
||||
│ │ ├── TextareaField.tsx # Textarea field with label
|
||||
│ │ └── FormButton.tsx # Submit button with styling
|
||||
│ │
|
||||
│ ├── panels/
|
||||
│ │ ├── topbar/
|
||||
│ │ │ ├── TopBar.tsx # Back button, domain badge, device switcher, undo/redo, save, templates
|
||||
│ │ │ ├── TemplateModal.tsx # Template browser with categories and one-click loading
|
||||
│ │ │ └── HeadCodeModal.tsx # Edits SiteDesign.headCode (site-wide, not per-page)
|
||||
│ │ ├── left/
|
||||
│ │ │ ├── LeftPanel.tsx # Tabs: Blocks | Pages | Layers | Assets
|
||||
│ │ │ ├── BlocksPanel.tsx # Draggable block toolbox with categories
|
||||
│ │ │ ├── PagesPanel.tsx # Multi-page CRUD, header/footer editing
|
||||
│ │ │ ├── LayersPanel.tsx # Component hierarchy tree view
|
||||
│ │ │ └── AssetsPanel.tsx # Asset browser with upload, drag-drop, thumbnails
|
||||
│ │ ├── right/
|
||||
│ │ │ ├── RightPanel.tsx # Single "Styles" tab -- renders GuidedStyles only
|
||||
│ │ │ ├── GuidedStyles.tsx # Context-aware dispatcher: picks a StylePanel by selected type
|
||||
│ │ │ ├── SiteDesignPanel.tsx # Site-wide design tokens editor (Basic/Advanced tabs)
|
||||
│ │ │ └── styles/ # Per-type StylePanels (Text, Button, Image, Container, Nav, Form, etc.)
|
||||
│ │ └── context-menu/
|
||||
│ │ └── ContextMenu.tsx # Right-click context menu (duplicate, copy, paste, delete, etc.)
|
||||
│ │
|
||||
│ ├── hooks/
|
||||
│ │ ├── useWhpApi.ts # Save/load/deploy via WHP API (with auto-save)
|
||||
│ │ ├── useAssets.ts # Asset upload, browse, delete via WHP API
|
||||
│ │ ├── useContextMenu.ts # Right-click menu state management
|
||||
│ │ └── useKeyboardShortcuts.ts # Keyboard shortcut handler (undo, redo, delete, etc.)
|
||||
│ │
|
||||
│ ├── templates/
|
||||
│ │ ├── index.ts # Template exports
|
||||
│ │ └── definitions.ts # 16 template definitions across 4 categories
|
||||
│ │
|
||||
│ ├── ui/
|
||||
│ │ ├── AssetPicker.tsx # Reusable image/video source picker (upload/browse-uploaded/URL), full+compact variants
|
||||
│ │ └── Modal.tsx # Reusable modal dialog shell
|
||||
│ │
|
||||
│ ├── constants/
|
||||
│ │ └── presets.ts # Color, font, spacing, radius, gradient, device width presets
|
||||
│ │
|
||||
│ ├── utils/
|
||||
│ │ ├── style-helpers.ts # cssPropsToString(), mergeStyles()
|
||||
│ │ └── html-export.ts # Recursive node-to-HTML renderer, full page export
|
||||
│ │
|
||||
│ └── styles/
|
||||
│ └── editor.css # Dark theme, CSS variables
|
||||
```
|
||||
|
||||
## Running Locally
|
||||
|
||||
```bash
|
||||
cd /workspace/site-builder/craft
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Opens at `http://localhost:5173`. The Vite dev server proxies `/api` requests to `http://192.168.1.105:8080` (the WHP staging server) for save/load during development.
|
||||
|
||||
In standalone mode (no WHP_CONFIG on `window`), the editor runs fully client-side without save/load functionality.
|
||||
|
||||
## Building
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
Runs `tsc && vite build`. Output goes to `dist/`:
|
||||
- `dist/index.html` - HTML shell
|
||||
- `dist/js/editor.js` - Single JS bundle
|
||||
- `dist/css/editor.css` - All styles
|
||||
- `dist/assets/` - Static assets (if any)
|
||||
|
||||
## Deploying to WHP
|
||||
|
||||
Copy the built `dist/` contents into the WHP site-builder web directory:
|
||||
|
||||
```bash
|
||||
# Build
|
||||
cd /workspace/site-builder/craft && npm run build
|
||||
|
||||
# Deploy to WHP Docker container
|
||||
cp dist/index.html /docker/whp/web/site-builder/editor.html
|
||||
cp -r dist/js/ /docker/whp/web/site-builder/js/
|
||||
cp -r dist/css/ /docker/whp/web/site-builder/css/
|
||||
```
|
||||
|
||||
The PHP wrapper (`/docker/whp/web/site-builder/index.php`) injects `WHP_CONFIG` into the HTML before serving it, so the editor gets the current user's session, CSRF token, site ID, etc.
|
||||
|
||||
## Architecture
|
||||
|
||||
### Key Decisions
|
||||
|
||||
1. **No iframe** - The canvas renders directly in the DOM (unlike GrapesJS which uses an iframe). This simplifies drag-and-drop and avoids cross-origin issues but means editor CSS must not leak into user content.
|
||||
|
||||
2. **Inline styles** - All component styling uses React `CSSProperties` (inline styles). No class-based CSS for user content. This makes HTML export trivial and avoids stylesheet management.
|
||||
|
||||
3. **Single Frame, multi-page** - Craft.js `<Frame>` holds one page at a time. Page switching serializes the current state, stores it, and deserializes the new page's state.
|
||||
|
||||
4. **Header/Footer as separate pages** - Header and Footer are stored as independent Craft.js states (like pages) that render above and below every page. Editing them uses the same canvas but with a distinct editing mode. This provides site-wide shared navigation and footer.
|
||||
|
||||
5. **API compatibility** - The save endpoint sends data in the same format as the GrapesJS version (`{ site_id, name, html, css, grapesjs: serializedJson }`), so the PHP backend doesn't need changes.
|
||||
|
||||
6. **Component-based architecture** - Each visual element is a React component that doubles as a Craft.js `UserComponent`. Rendering and HTML export are co-located in one file; style editing is handled by a shared per-type `StylePanel` in `src/panels/right/styles/` rather than a per-component settings panel.
|
||||
|
||||
7. **Site Design Tokens** - A `SiteDesignContext` provides 17 design properties (colors, fonts, radii, nav style) that components can reference. Templates import their own design tokens when loaded.
|
||||
|
||||
### Component Architecture
|
||||
|
||||
Every component in `src/components/` follows this pattern:
|
||||
|
||||
```typescript
|
||||
import { UserComponent, useNode } from '@craftjs/core';
|
||||
|
||||
// 1. Props interface
|
||||
interface MyComponentProps {
|
||||
text?: string;
|
||||
style?: CSSProperties;
|
||||
}
|
||||
|
||||
// 2. The component itself (renders in editor canvas)
|
||||
export const MyComponent: UserComponent<MyComponentProps> = ({ text, style }) => {
|
||||
const { connectors: { connect, drag } } = useNode();
|
||||
return <div ref={(r) => { if (r) connect(drag(r)); }} style={style}>{text}</div>;
|
||||
};
|
||||
|
||||
// 3. Craft config (displayName, default props, rules)
|
||||
MyComponent.craft = {
|
||||
displayName: 'My Component',
|
||||
props: { text: 'Default text', style: {} },
|
||||
rules: { canDrag: () => true, canMoveIn: () => false, canMoveOut: () => true },
|
||||
};
|
||||
|
||||
// 4. HTML export (static method for serializing to HTML string)
|
||||
(MyComponent as any).toHtml = (props: MyComponentProps, childrenHtml: string) => {
|
||||
return { html: `<div style="...">${childrenHtml}</div>` };
|
||||
};
|
||||
```
|
||||
|
||||
Style editing for the new type is added separately as a `StylePanel` under `src/panels/right/styles/` (or reuses an existing generic one), and wired into `GuidedStyles.tsx`'s type dispatch -- components no longer carry their own settings UI.
|
||||
|
||||
### Component Resolver
|
||||
|
||||
All components must be registered in `src/components/resolver.ts`. This map is passed to `<Editor resolver={componentResolver}>` so Craft.js can serialize/deserialize the node tree.
|
||||
|
||||
```typescript
|
||||
export const componentResolver = {
|
||||
Container, Section, ColumnLayout, BackgroundSection,
|
||||
Heading, TextBlock, ButtonLink, Navbar, Footer, Divider, Spacer,
|
||||
ImageBlock, VideoBlock,
|
||||
HeroSimple, FeaturesGrid, CTASection,
|
||||
FormContainer, InputField, TextareaField, FormButton,
|
||||
};
|
||||
```
|
||||
|
||||
## WHP Integration
|
||||
|
||||
### PHP Wrapper
|
||||
|
||||
The WHP control panel serves the editor through `index.php`, which:
|
||||
1. Verifies user authentication
|
||||
2. Validates the `site_id` parameter
|
||||
3. Generates a CSRF token
|
||||
4. Injects a `WHP_CONFIG` object into the HTML as a `<script>` tag before the app bundle
|
||||
|
||||
```javascript
|
||||
window.WHP_CONFIG = {
|
||||
user: "username",
|
||||
apiUrl: "/panel/api/site-builder",
|
||||
csrfToken: "abc123...",
|
||||
siteId: 42,
|
||||
siteDomain: "example.com",
|
||||
siteName: "My Site",
|
||||
backUrl: "/panel/sites",
|
||||
isRoot: false
|
||||
};
|
||||
```
|
||||
|
||||
### API Endpoints
|
||||
|
||||
The editor communicates with WHP through these endpoints (all require CSRF token):
|
||||
|
||||
| Method | Endpoint | Description |
|
||||
|--------|----------|-------------|
|
||||
| POST | `/panel/api/site-builder?action=save` | Save project (JSON body with site_id, html, css, craft state) |
|
||||
| GET | `/panel/api/site-builder?action=load&site_id=N` | Load project for a site |
|
||||
| POST | `/panel/api/site-builder?action=upload` | Upload asset (multipart) |
|
||||
| GET | `/panel/api/site-builder?action=assets&site_id=N` | List assets for a site |
|
||||
| DELETE | `/panel/api/site-builder?action=delete_asset` | Delete an asset |
|
||||
| POST | `/panel/api/site-builder?action=deploy&site_id=N` | Deploy/publish site to document root |
|
||||
|
||||
### Auto-Save
|
||||
|
||||
The editor auto-saves every 30 seconds when running inside WHP. The save status is displayed in the top bar.
|
||||
|
||||
### EditorConfigContext
|
||||
|
||||
`useEditorConfig()` provides access to `WHP_CONFIG` throughout the React tree:
|
||||
- `whpConfig` - The full config object (or null in standalone mode)
|
||||
- `isWHP` - Boolean shorthand for whether we're running inside WHP
|
||||
|
||||
## All Components (39)
|
||||
|
||||
| # | Component | Type | File | Features |
|
||||
|---|-----------|------|------|----------|
|
||||
| 1 | Container | Layout | `layout/Container.tsx` | Generic wrapper, tag selector (div/section/article/header/footer/main), bg color, padding, radius |
|
||||
| 2 | Section | Layout | `layout/Section.tsx` | Full-width with centered inner container, bg color/gradient, vertical padding, inner max-width |
|
||||
| 3 | ColumnLayout | Layout | `layout/ColumnLayout.tsx` | 1-6 columns, split ratios (50-50, 30-70, 70-30, 33-33-33, 25-25-25-25, etc.), gap control |
|
||||
| 4 | BackgroundSection | Layout | `layout/BackgroundSection.tsx` | Section with background image, gradient overlay, parallax-ready |
|
||||
| 5 | Heading | Basic | `basic/Heading.tsx` | Inline-editable, h1-h6 level, color, font family/size/weight, text align |
|
||||
| 6 | TextBlock | Basic | `basic/TextBlock.tsx` | Inline-editable paragraph, color, font family/size/weight, text align, line height |
|
||||
| 7 | ButtonLink | Basic | `basic/ButtonLink.tsx` | Link text/URL/target, 8 color presets (auto text contrast), radius, padding, font size |
|
||||
| 8 | Logo | Basic | `basic/Logo.tsx` | Text or image logo, link href, font family/size/weight, image width |
|
||||
| 9 | Menu | Basic | `basic/Menu.tsx` | Link list with optional CTA styling, horizontal/vertical orientation, alignment, hover colors |
|
||||
| 10 | Navbar | Basic | `basic/Navbar.tsx` | Text or image logo, page links, external links, CTA buttons, light/dark nav style |
|
||||
| 11 | Footer | Basic | `basic/Footer.tsx` | Footer with links, copyright, social links |
|
||||
| 12 | Divider | Basic | `basic/Divider.tsx` | Horizontal rule with color and thickness controls |
|
||||
| 13 | Spacer | Basic | `basic/Spacer.tsx` | Vertical spacing element with height control |
|
||||
| 14 | Icon | Basic | `basic/Icon.tsx` | Font Awesome icon, size/color, background shape, optional link |
|
||||
| 15 | ImageBlock | Media | `media/ImageBlock.tsx` | SVG placeholder, URL input, upload, browse assets, alt text, width/height, object-fit, radius |
|
||||
| 16 | VideoBlock | Media | `media/VideoBlock.tsx` | YouTube, Vimeo, direct files (.mp4/.webm/.ogg), background mode, autoplay, loop |
|
||||
| 17 | MapEmbed | Media | `media/MapEmbed.tsx` | Embedded map by address, zoom level, height |
|
||||
| 18 | HeroSimple | Section | `sections/HeroSimple.tsx` | Pre-built hero with heading, subtext, CTA button, gradient/color background |
|
||||
| 19 | FeaturesGrid | Section | `sections/FeaturesGrid.tsx` | 3-column feature cards with icons, titles, descriptions |
|
||||
| 20 | CTASection | Section | `sections/CTASection.tsx` | Call-to-action banner with heading, text, button |
|
||||
| 21 | Countdown | Section | `sections/Countdown.tsx` | Countdown timer to a target date, heading, digit/label colors |
|
||||
| 22 | Testimonials | Section | `sections/Testimonials.tsx` | Grid or single-layout testimonial cards, star color, card background |
|
||||
| 23 | FormContainer | Form | `forms/FormContainer.tsx` | Form wrapper with action URL and method |
|
||||
| 24 | InputField | Form | `forms/InputField.tsx` | Text input with label, placeholder, type (text/email/tel/password/number) |
|
||||
| 25 | TextareaField | Form | `forms/TextareaField.tsx` | Textarea with label and placeholder |
|
||||
| 26 | FormButton | Form | `forms/FormButton.tsx` | Submit button with color and style controls |
|
||||
| 27 | ContactForm | Form | `forms/ContactForm.tsx` | Configurable field list, recipient email, success message or thank-you URL redirect |
|
||||
| 28 | StarRating | Basic | `basic/StarRating.tsx` | Star rating display, rating/max stars, filled/empty color |
|
||||
| 29 | SocialLinks | Basic | `basic/SocialLinks.tsx` | Social icon links, size/color/shape, gap, alignment |
|
||||
| 30 | CallToAction | Section | `sections/CallToAction.tsx` | Heading/description with 1-2 buttons, color/gradient/image background with overlay |
|
||||
| 31 | Accordion | Section | `sections/Accordion.tsx` | Expand/collapse item list, header/content colors |
|
||||
| 32 | Tabs | Section | `sections/Tabs.tsx` | Tabbed content panels, active/inactive tab colors |
|
||||
| 33 | PricingTable | Section | `sections/PricingTable.tsx` | Pricing plan cards, featured plan highlight, bullet list |
|
||||
| 34 | Gallery | Section | `sections/Gallery.tsx` | Image grid gallery, configurable columns/gap, optional lightbox |
|
||||
| 35 | ContentSlider | Section | `sections/ContentSlider.tsx` | Auto-playing image/content slider, dots/arrows, configurable interval |
|
||||
| 36 | NumberCounter | Section | `sections/NumberCounter.tsx` | Animated stat counters, columns, number/label colors |
|
||||
| 37 | SubscribeForm | Form | `forms/SubscribeForm.tsx` | Inline/stacked email signup form, heading, button color |
|
||||
| 38 | SearchBar | Basic | `basic/SearchBar.tsx` | Search input with optional button, placeholder text |
|
||||
| 39 | HtmlBlock | Basic | `basic/HtmlBlock.tsx` | Raw/custom HTML embed block, sanitized on export |
|
||||
|
||||
## Site Design Tokens
|
||||
|
||||
The `SiteDesignContext` provides 17 design properties organized into Basic and Advanced tabs:
|
||||
|
||||
### Basic Tab (6 properties)
|
||||
| Property | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| primaryColor | `#3b82f6` | Primary brand color |
|
||||
| secondaryColor | `#8b5cf6` | Secondary brand color |
|
||||
| accentColor | `#10b981` | Accent/highlight color |
|
||||
| headingFont | `Inter, sans-serif` | Font for headings |
|
||||
| bodyFont | `Inter, sans-serif` | Font for body text |
|
||||
| linkColor | `#3b82f6` | Default link color |
|
||||
|
||||
### Advanced Tab (11 properties)
|
||||
| Property | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| successColor | `#10b981` | Success state color |
|
||||
| warningColor | `#f59e0b` | Warning state color |
|
||||
| errorColor | `#ef4444` | Error state color |
|
||||
| backgroundColor | `#ffffff` | Page background |
|
||||
| textColor | `#1f2937` | Default text color |
|
||||
| mutedTextColor | `#6b7280` | Muted/secondary text |
|
||||
| borderColor | `#e5e7eb` | Default border color |
|
||||
| borderRadius | `8px` | Global border radius |
|
||||
| buttonFont | `Inter, sans-serif` | Button font family |
|
||||
| buttonRadius | `8px` | Button border radius |
|
||||
| navStyle | `light` | Navbar style (light/dark) |
|
||||
|
||||
Design tokens are imported from templates and can be edited via the Site Design panel. Components can read these tokens via `useSiteDesign()`.
|
||||
|
||||
## Templates
|
||||
|
||||
16 pre-built templates organized into 4 categories:
|
||||
|
||||
| Category | Templates |
|
||||
|----------|-----------|
|
||||
| Business | Restaurant, Small Business, SaaS Landing, Agency, Medical |
|
||||
| Creative | Portfolio, Photography, Content Creator, Event/Conference |
|
||||
| Personal | Resume/CV, Blog, Wedding, Coming Soon |
|
||||
| Community | Church, Non-Profit, Fitness/Gym |
|
||||
|
||||
Each template includes:
|
||||
- Site design tokens (color palette, fonts)
|
||||
- Header content (Navbar)
|
||||
- Footer content
|
||||
- One or more pages with pre-built content
|
||||
- SVG thumbnail preview
|
||||
|
||||
Templates are loaded via the Template Modal (opened from TopBar). Loading a template replaces all pages, header, footer, and optionally imports the design tokens.
|
||||
|
||||
## Multi-Page System
|
||||
|
||||
Pages are managed through `PageContext`:
|
||||
- Each page has: `id`, `name`, `slug`, `craftState` (`headCode` is site-wide only, on `SiteDesignContext`/`SiteDesign`, edited via the TopBar's Head Code modal -- not a per-page field)
|
||||
- Header and Footer are stored as separate "page" entries with fixed IDs (`__header__`, `__footer__`)
|
||||
- Page switching serializes the current canvas, stores it, then deserializes the target page
|
||||
- Header/Footer editing puts the canvas in a distinct mode
|
||||
- The PagesPanel provides add, rename, delete, reorder, and header/footer edit buttons
|
||||
|
||||
## Keyboard Shortcuts
|
||||
|
||||
| Shortcut | Action |
|
||||
|----------|--------|
|
||||
| Ctrl/Cmd + Z | Undo |
|
||||
| Ctrl/Cmd + Shift + Z | Redo |
|
||||
| Ctrl/Cmd + Y | Redo (alternative) |
|
||||
| Delete / Backspace | Delete selected element |
|
||||
|
||||
Shortcuts are disabled when focus is on input, textarea, select, or contentEditable elements.
|
||||
|
||||
## Context Menu (Right-Click)
|
||||
|
||||
The context menu appears on right-click within the canvas and provides:
|
||||
- Duplicate element
|
||||
- Copy / Paste
|
||||
- Move Up / Move Down
|
||||
- Select Parent
|
||||
- Delete (with danger styling)
|
||||
|
||||
Items are disabled contextually (e.g., cannot delete ROOT, cannot move if already first/last).
|
||||
|
||||
## Layers Panel
|
||||
|
||||
The Layers panel shows a hierarchical tree of all components on the current page. Clicking a layer selects the corresponding component. The tree displays component display names and nests children with indentation.
|
||||
|
||||
## Asset Management
|
||||
|
||||
The Assets panel (`AssetsPanel.tsx`) provides:
|
||||
- Upload button and drag-and-drop zone
|
||||
- Thumbnail grid of uploaded assets
|
||||
- Copy URL to clipboard
|
||||
- Delete asset
|
||||
- Integration with WHP API for server-side storage
|
||||
|
||||
Image and video fields elsewhere in the editor (ImageStylePanel, MediaStylePanel, HeroStylePanel, NavStylePanel, BackgroundSectionStylePanel, and array-editor cards like FeaturesEditor) use the shared `AssetPicker` (`src/ui/AssetPicker.tsx`) for upload / browse-uploaded / paste-URL, in a `full` or `compact` variant depending on space.
|
||||
|
||||
## Adding New Components
|
||||
|
||||
1. Create `src/components/<category>/<ComponentName>.tsx` following the pattern above
|
||||
2. Add the component to `src/components/resolver.ts`
|
||||
3. Add a block entry in `src/panels/left/BlocksPanel.tsx` under the appropriate category
|
||||
4. Add or extend a `StylePanel` in `src/panels/right/styles/` and wire it into `GuidedStyles.tsx`'s type dispatch so the new component is editable when selected
|
||||
5. Implement the `toHtml` static for HTML export
|
||||
6. Build and test: `npm run dev`, drag the block onto the canvas, verify the style panel, verify HTML export
|
||||
|
||||
### Checklist for a new component:
|
||||
- [ ] Props interface with `style?: CSSProperties`
|
||||
- [ ] `useNode()` with `connect(drag(ref))` on the root element
|
||||
- [ ] `StylePanel` entry (new or reused) wired into `GuidedStyles.tsx`
|
||||
- [ ] `.craft` config with `displayName`, default `props`, `rules`
|
||||
- [ ] `.toHtml()` static method using `cssPropsToString()`
|
||||
- [ ] Registered in `resolver.ts`
|
||||
- [ ] Block added to `BlocksPanel.tsx`
|
||||
|
||||
## CSS / Theme
|
||||
|
||||
The editor uses a dark theme defined via CSS custom properties in `src/styles/editor.css`:
|
||||
- **Base:** `#16161a`
|
||||
- **Surface:** `#1c1c24`
|
||||
- **Accent:** `#3b82f6` (blue)
|
||||
- **Text:** `#e4e4e7`
|
||||
- **Border:** `#2d2d3a`
|
||||
- **Font:** Inter
|
||||
|
||||
All editor chrome (panels, topbar, style panels) is styled via `editor.css`. User content on the canvas uses inline styles exclusively.
|
||||
|
||||
## Presets
|
||||
|
||||
Style presets are defined in `src/constants/presets.ts`:
|
||||
- `TEXT_COLORS` - 8 text color swatches
|
||||
- `BG_COLORS` - 8 background color swatches
|
||||
- `FONT_FAMILIES` - 8 Google Fonts
|
||||
- `TEXT_SIZES` - XS through 2XL
|
||||
- `FONT_WEIGHTS` - Light through Bold
|
||||
- `SPACING_PRESETS` - None through XL
|
||||
- `RADIUS_PRESETS` - None through Full (9999px)
|
||||
- `GRADIENTS` - 12 gradient presets
|
||||
- `DEVICE_WIDTHS` - Desktop (100%), Tablet (768px), Mobile (375px)
|
||||
|
||||
## HTML Export
|
||||
|
||||
Every component has a static `toHtml(props, childrenHtml)` method. The `html-export.ts` utility recursively walks the Craft.js node tree and calls each component's `toHtml` to produce a complete HTML document. Export includes:
|
||||
- Full `<!DOCTYPE html>` document structure
|
||||
- Google Fonts preload links (optional)
|
||||
- Inline styles throughout
|
||||
- Header and footer wrapping each page
|
||||
|
||||
## Testing Approach
|
||||
|
||||
- **Manual testing:** Run `npm run dev`, drag components, edit props, verify style panels
|
||||
- **Type checking:** `tsc --noEmit` (part of build step)
|
||||
- **HTML export:** Verify `toHtml()` output matches expected HTML structure
|
||||
- **Device preview:** Switch between desktop/tablet/mobile and verify responsive behavior
|
||||
- **WHP integration:** Deploy to staging, verify save/load, verify PHP wrapper injection
|
||||
|
||||
## Development Notes
|
||||
|
||||
- Path alias `@/` maps to `./src/` (configured in both tsconfig.json and vite.config.ts)
|
||||
- `GuidedStyles` shows the selected component type and dispatches to a shared `StylePanel` in `src/panels/right/styles/` (components no longer carry their own settings UI)
|
||||
- Text components (Heading, TextBlock) use `contentEditable` for inline editing when selected
|
||||
- Button/link navigation is prevented in the editor via `e.preventDefault()`
|
||||
- Image upload integrates with WHP API; in standalone mode falls back to local `blob:` URLs
|
||||
- Auto-save runs every 30 seconds when connected to WHP API
|
||||
- `AssetPicker` (`src/ui/AssetPicker.tsx`) is the shared upload/browse/URL control reused across StylePanels for every image and video field
|
||||
@@ -0,0 +1,102 @@
|
||||
# WHP Site Builder - Features
|
||||
|
||||
## Visual Editor
|
||||
- Drag-and-drop page building with real-time preview
|
||||
- No iframe -- content renders directly in the editor
|
||||
- Responsive device preview (Desktop / Tablet / Mobile)
|
||||
- Undo / Redo with full history
|
||||
- Auto-save every 30 seconds
|
||||
- Save as draft / Publish workflow
|
||||
|
||||
## Components (36 modules)
|
||||
|
||||
### Layout
|
||||
- **Container** -- Generic wrapper with bg color/gradient/image, parallax, overlay
|
||||
- **Section** -- Full-width section with centered content area
|
||||
- **Column Layout** -- 1-10 columns with preset and custom splits
|
||||
- **Background Section** -- Section with background image/gradient overlay
|
||||
|
||||
### Content
|
||||
- **Heading** -- H1-H6 with inline editing, full typography controls
|
||||
- **Text Block** -- Paragraph with inline editing
|
||||
- **Button / Link** -- Styled button with color presets, radius, padding
|
||||
- **Divider** -- Horizontal rule with color and thickness
|
||||
- **Spacer** -- Adjustable vertical spacing
|
||||
- **Icon** -- Font Awesome icon with size, color, background shape
|
||||
- **Star Rating** -- Decorative star display (0-5 stars)
|
||||
- **Social Links** -- Social media icon links (10+ platforms)
|
||||
|
||||
### Navigation
|
||||
- **Logo** -- Text or image logo with link
|
||||
- **Menu** -- Navigation links with page integration, CTA support
|
||||
- **Navbar** -- Combined logo + menu layout (convenience block)
|
||||
|
||||
### Media
|
||||
- **Image** -- Upload, browse, drag-drop, sizing controls (width/height/max-width with units)
|
||||
- **Video** -- YouTube, Vimeo, direct files, background mode with overlay
|
||||
- **Gallery** -- Image grid (2-6 columns) with optional lightbox
|
||||
- **Map Embed** -- Google Maps with address, zoom, height
|
||||
|
||||
### Sections (Pre-built)
|
||||
- **Hero** -- Gradient hero with heading, subtitle, CTA button
|
||||
- **Features Grid** -- Multi-column feature cards
|
||||
- **Call to Action** -- Full CTA section with bg options and dual buttons
|
||||
- **Pricing Table** -- 2-4 tier comparison cards with featured plan
|
||||
- **Testimonials** -- Quote cards with star ratings (grid or single view)
|
||||
- **Accordion** -- Expandable FAQ/content panels
|
||||
- **Tabs** -- Tabbed content panels
|
||||
- **Countdown** -- Date countdown timer with live updating
|
||||
- **Footer** -- Footer component with copyright text
|
||||
|
||||
### Forms
|
||||
- **Contact Form** -- Complete form with configurable fields
|
||||
- **Form Container** -- Wrapper for custom form layouts
|
||||
- **Input Field** -- Text/email/tel/password/number inputs
|
||||
- **Textarea** -- Multi-line text input
|
||||
- **Form Button** -- Submit button with styling
|
||||
|
||||
## Site Design System
|
||||
- **Basic mode**: Primary, secondary, accent colors + heading/body fonts + link color
|
||||
- **Advanced mode**: Success/warning/error colors, background/text/border colors, border radius, button styling, nav style
|
||||
- Design tokens applied across all pages and components
|
||||
- Reset to defaults button
|
||||
|
||||
## Pages & Structure
|
||||
- Multi-page support with unlimited pages
|
||||
- **Header** -- Shared across all pages, edited separately
|
||||
- **Footer** -- Shared across all pages, edited separately
|
||||
- Clean URLs via .htaccess (no .html extension)
|
||||
- Page management: add, edit, rename, delete, reorder
|
||||
|
||||
## Templates (16 pre-built)
|
||||
- **Business**: Restaurant, Small Business, SaaS, Agency, Medical
|
||||
- **Creative**: Portfolio, Photography, Content Creator, Event
|
||||
- **Personal**: Resume, Blog, Wedding, Coming Soon
|
||||
- **Community**: Church, Non-Profit, Fitness
|
||||
- Each includes header, footer, and design tokens
|
||||
- One-click loading with optional design token import
|
||||
|
||||
## Asset Management
|
||||
- Image/video upload with drag-and-drop
|
||||
- Asset browser with thumbnails
|
||||
- Inline asset selection in component settings
|
||||
- Assets stored in staging area, deployed on publish
|
||||
|
||||
## Settings Per Component (3-tab system)
|
||||
- **General** -- Content-specific settings
|
||||
- **Style** -- Typography, colors, spacing, borders, backgrounds
|
||||
- **Advanced** -- Margin/padding (4-sided), CSS class/ID, responsive visibility, entrance animations
|
||||
|
||||
## Editor Features
|
||||
- Component tree (Layers panel)
|
||||
- Right-click context menu (duplicate, copy, paste, move, delete)
|
||||
- Keyboard shortcuts (Ctrl+Z/Y/C/V/D, Delete, Escape)
|
||||
- Dashed outlines for all containers/rows/columns
|
||||
- Component selection with blue outline indicator
|
||||
|
||||
## Publishing
|
||||
- **Save** stores to staging area (.site-builder/) -- does not affect live site
|
||||
- **Publish** deploys to document root with header + body + footer composition
|
||||
- Preview in new tab with header and footer included
|
||||
- Coming Soon / Go Live toggle
|
||||
- Clean HTML output with Google Fonts and responsive CSS
|
||||
@@ -0,0 +1,75 @@
|
||||
# WHP Site Builder v2
|
||||
|
||||
A visual drag-and-drop website builder for WHP, rebuilt from the ground up with Craft.js, React 18, and TypeScript. Replaces the legacy GrapesJS-based editor.
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Opens at http://localhost:5173. The editor runs in standalone mode without WHP integration.
|
||||
|
||||
## Build and Deploy
|
||||
|
||||
```bash
|
||||
# Build production bundle
|
||||
npm run build
|
||||
|
||||
# Deploy to WHP
|
||||
cp dist/index.html /docker/whp/web/site-builder/editor.html
|
||||
cp -r dist/js/ /docker/whp/web/site-builder/js/
|
||||
cp -r dist/css/ /docker/whp/web/site-builder/css/
|
||||
```
|
||||
|
||||
The PHP wrapper (`index.php`) injects `WHP_CONFIG` (user session, CSRF token, site ID) into the HTML before serving.
|
||||
|
||||
## Architecture
|
||||
|
||||
- **Craft.js** - React-based visual editor framework (no iframe, direct DOM rendering)
|
||||
- **Inline styles** - All user content uses React CSSProperties, no class-based CSS
|
||||
- **Component pattern** - Each component is a self-contained file with: render logic, settings panel, Craft.js config, and HTML export method
|
||||
- **3-panel layout** - Left (blocks/pages/layers/assets), Center (canvas with device preview), Right (styles/settings/head)
|
||||
- **Dark theme** - CSS custom properties, Inter font, blue accent
|
||||
|
||||
## Components (22)
|
||||
|
||||
| Category | Components |
|
||||
|----------|-----------|
|
||||
| Layout | Container, Section, ColumnLayout (1-6 cols), BackgroundSection, HeaderZone, FooterZone |
|
||||
| Basic | Heading (H1-H6), TextBlock, ButtonLink, Navbar, Footer, Divider, Spacer |
|
||||
| Media | ImageBlock (upload/browse/drag-drop), VideoBlock (YouTube/Vimeo/direct/background) |
|
||||
| Sections | HeroSimple, FeaturesGrid, CTASection |
|
||||
| Forms | FormContainer, InputField, TextareaField, FormButton |
|
||||
|
||||
## Features
|
||||
|
||||
- **Visual Editor** - Drag-and-drop building, real-time preview, responsive device preview (Desktop/Tablet/Mobile)
|
||||
- **Site Design Tokens** - 17 site-wide properties (colors, fonts, radii, nav style) with Basic/Advanced tabs
|
||||
- **Multi-Page** - Unlimited pages with shared Header and Footer across all pages
|
||||
- **16 Templates** - Pre-built designs across 4 categories (Business, Creative, Personal, Community)
|
||||
- **Asset Management** - Upload, browse, drag-drop, thumbnails, server-side storage via WHP API
|
||||
- **HTML Export** - Full document export with Google Fonts and inline styles
|
||||
- **Auto-Save** - Saves every 30 seconds when connected to WHP
|
||||
- **Context Menu** - Right-click for duplicate, copy, paste, move, delete
|
||||
- **Keyboard Shortcuts** - Undo, redo, delete
|
||||
- **Layers Panel** - Component hierarchy tree view
|
||||
- **Undo/Redo** - Full history support
|
||||
|
||||
## Key Files
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `src/main.tsx` | Entry point, reads WHP_CONFIG |
|
||||
| `src/App.tsx` | Editor + providers (EditorConfig, SiteDesign, Pages) |
|
||||
| `src/components/resolver.ts` | Component registry (20 components) for serialization |
|
||||
| `src/editor/EditorShell.tsx` | 3-panel layout + context menu + keyboard shortcuts |
|
||||
| `src/editor/Canvas.tsx` | Craft.js Frame with device switching |
|
||||
| `src/state/PageContext.tsx` | Multi-page state + header/footer |
|
||||
| `src/state/SiteDesignContext.tsx` | 17 site-wide design tokens |
|
||||
| `src/templates/definitions.ts` | 16 template definitions |
|
||||
| `src/constants/presets.ts` | Color, font, spacing presets |
|
||||
| `src/utils/html-export.ts` | Node-tree to HTML renderer |
|
||||
|
||||
See `CLAUDE.md` for full documentation. See `FEATURES.md` for a complete feature list.
|
||||
@@ -0,0 +1,16 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||
<title>Site Builder</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
|
||||
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/4.7.0/css/font-awesome.min.css" rel="stylesheet">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+3406
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"name": "whp-site-builder",
|
||||
"private": true,
|
||||
"version": "2.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview",
|
||||
"test": "playwright test tests/site-builder.spec.ts --reporter=list",
|
||||
"test:headed": "playwright test tests/site-builder.spec.ts --reporter=list --headed",
|
||||
"test:e2e:sitesmith": "playwright test tests/sitesmith.spec.ts --reporter=list",
|
||||
"test:unit": "vitest run",
|
||||
"test:unit:watch": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"@codemirror/autocomplete": "^6.20.3",
|
||||
"@codemirror/commands": "^6.10.4",
|
||||
"@codemirror/lang-css": "^6.3.1",
|
||||
"@codemirror/lang-html": "^6.4.11",
|
||||
"@codemirror/lang-javascript": "^6.2.5",
|
||||
"@codemirror/language": "^6.12.4",
|
||||
"@codemirror/state": "^6.7.1",
|
||||
"@codemirror/theme-one-dark": "^6.1.3",
|
||||
"@codemirror/view": "^6.43.6",
|
||||
"@craftjs/core": "^0.2.10",
|
||||
"@craftjs/layers": "^0.2.7",
|
||||
"dompurify": "^3.4.5",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.59.1",
|
||||
"@types/dompurify": "^3.0.5",
|
||||
"@types/react": "^18.3.12",
|
||||
"@types/react-dom": "^18.3.1",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"@vitest/ui": "^4.1.7",
|
||||
"jsdom": "^29.1.1",
|
||||
"typescript": "^5.6.3",
|
||||
"vite": "^6.0.5",
|
||||
"vitest": "^4.1.7"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import React from 'react';
|
||||
import { Editor } from '@craftjs/core';
|
||||
import { EditorShell } from './editor/EditorShell';
|
||||
import { RenderNode } from './editor/RenderNode';
|
||||
import { componentResolver } from './components/resolver';
|
||||
import { WhpConfig } from './types';
|
||||
import { EditorConfigProvider } from './state/EditorConfigContext';
|
||||
import { SiteDesignProvider } from './state/SiteDesignContext';
|
||||
import { PageProvider } from './state/PageContext';
|
||||
import { SitesmithProvider, useSitesmithModal } from './state/SitesmithContext';
|
||||
import { SitesmithModal } from './panels/sitesmith/SitesmithModal';
|
||||
|
||||
interface AppProps {
|
||||
whpConfig: WhpConfig | null;
|
||||
}
|
||||
|
||||
const SitesmithModalMount: React.FC = () => {
|
||||
const { isOpen, target, close } = useSitesmithModal();
|
||||
if (!isOpen) return null;
|
||||
return <SitesmithModal target={target} onClose={close} />;
|
||||
};
|
||||
|
||||
export const App: React.FC<AppProps> = ({ whpConfig }) => {
|
||||
return (
|
||||
<EditorConfigProvider config={whpConfig}>
|
||||
<SiteDesignProvider>
|
||||
<Editor resolver={componentResolver} enabled={true} onRender={RenderNode}>
|
||||
<PageProvider>
|
||||
<SitesmithProvider>
|
||||
<EditorShell />
|
||||
<SitesmithModalMount />
|
||||
</SitesmithProvider>
|
||||
</PageProvider>
|
||||
</Editor>
|
||||
</SiteDesignProvider>
|
||||
</EditorConfigProvider>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,54 @@
|
||||
import { describe, test, expect } from 'vitest';
|
||||
import { ButtonLink } from './ButtonLink';
|
||||
|
||||
const toHtml = (ButtonLink as any).toHtml;
|
||||
|
||||
describe('ButtonLink.toHtml href sanitization (attacker-controlled `href` prop)', () => {
|
||||
test('a javascript: URL is neutralized', () => {
|
||||
const { html } = toHtml({ href: 'javascript:alert(1)', text: 'Click' }, '');
|
||||
expect(html).not.toContain('javascript:alert');
|
||||
});
|
||||
|
||||
test('a quote-breakout href does not escape the href attribute', () => {
|
||||
const malicious = '"><script>alert(1)</script>';
|
||||
const { html } = toHtml({ href: malicious, text: 'Click' }, '');
|
||||
expect(html).not.toContain('<script>alert(1)</script>');
|
||||
});
|
||||
|
||||
test('a normal href still renders correctly', () => {
|
||||
const { html } = toHtml({ href: 'https://example.com', text: 'Click' }, '');
|
||||
expect(html).toContain('href="https://example.com"');
|
||||
});
|
||||
});
|
||||
|
||||
describe('ButtonLink.toHtml target (boolean-gated, not raw interpolation)', () => {
|
||||
test('an attribute-breakout value for target does not reach the output raw', () => {
|
||||
const malicious = '_blank" onmouseover="alert(1)' as any;
|
||||
const { html } = toHtml({ href: '#', text: 'x', target: malicious }, '');
|
||||
expect(html).not.toContain('onmouseover');
|
||||
});
|
||||
|
||||
test('target="_blank" still adds rel=noopener noreferrer', () => {
|
||||
const { html } = toHtml({ href: '#', text: 'x', target: '_blank' }, '');
|
||||
expect(html).toContain('target="_blank"');
|
||||
expect(html).toContain('rel="noopener noreferrer"');
|
||||
});
|
||||
});
|
||||
|
||||
describe('ButtonLink.toHtml text escaping (attacker-controlled `text` prop)', () => {
|
||||
test('a tag-breakout attempt in text is neutralized (no injected element)', () => {
|
||||
const { html } = toHtml({ href: '#', text: '</a><img src=x onerror=alert(1)>' }, '');
|
||||
expect(html).not.toContain('<img');
|
||||
expect(html).toContain('<img');
|
||||
});
|
||||
|
||||
test('ampersand is escaped for well-formed text content (consistency with escapeHtml)', () => {
|
||||
const { html } = toHtml({ href: '#', text: 'Tom & Jerry' }, '');
|
||||
expect(html).toContain('Tom & Jerry');
|
||||
});
|
||||
|
||||
test('a normal text value still renders unchanged', () => {
|
||||
const { html } = toHtml({ href: '#', text: 'Click Me' }, '');
|
||||
expect(html).toContain('>Click Me</a>');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,86 @@
|
||||
import React, { CSSProperties } from 'react';
|
||||
import { useNode, UserComponent } from '@craftjs/core';
|
||||
import { cssPropsToString } from '../../utils/style-helpers';
|
||||
import { escapeHtml, escapeAttr, safeUrl } from '../../utils/escape';
|
||||
|
||||
interface ButtonLinkProps {
|
||||
text?: string;
|
||||
href?: string;
|
||||
target?: '_self' | '_blank';
|
||||
style?: CSSProperties;
|
||||
}
|
||||
|
||||
export const ButtonLink: UserComponent<ButtonLinkProps> = ({
|
||||
text = 'Click Me',
|
||||
href = '#',
|
||||
target = '_self',
|
||||
style = {},
|
||||
}) => {
|
||||
const {
|
||||
connectors: { connect, drag },
|
||||
selected,
|
||||
} = useNode((node) => ({
|
||||
selected: node.events.selected,
|
||||
}));
|
||||
|
||||
return (
|
||||
<a
|
||||
ref={(ref: HTMLAnchorElement | null) => { if (ref) connect(drag(ref)); }}
|
||||
href={href}
|
||||
target={target}
|
||||
onClick={(e) => {
|
||||
// Prevent navigation inside editor
|
||||
e.preventDefault();
|
||||
}}
|
||||
style={{
|
||||
display: 'inline-block',
|
||||
textDecoration: 'none',
|
||||
cursor: 'pointer',
|
||||
outline: selected ? '2px solid #3b82f6' : 'none',
|
||||
...style,
|
||||
}}
|
||||
>
|
||||
{text}
|
||||
</a>
|
||||
);
|
||||
};
|
||||
|
||||
/* ---------- Craft config ---------- */
|
||||
|
||||
ButtonLink.craft = {
|
||||
displayName: 'Button',
|
||||
props: {
|
||||
text: 'Click Me',
|
||||
href: '#',
|
||||
target: '_self',
|
||||
style: {
|
||||
backgroundColor: '#3b82f6',
|
||||
color: '#ffffff',
|
||||
padding: '12px 24px',
|
||||
borderRadius: '8px',
|
||||
fontWeight: '600',
|
||||
fontSize: '16px',
|
||||
border: 'none',
|
||||
},
|
||||
},
|
||||
rules: {
|
||||
canDrag: () => true,
|
||||
canMoveIn: () => false,
|
||||
canMoveOut: () => true,
|
||||
},
|
||||
};
|
||||
|
||||
/* ---------- HTML export ---------- */
|
||||
|
||||
(ButtonLink as any).toHtml = (props: ButtonLinkProps, _childrenHtml: string) => {
|
||||
const styleStr = cssPropsToString({
|
||||
display: 'inline-block',
|
||||
textDecoration: 'none',
|
||||
...props.style,
|
||||
});
|
||||
const escapedText = escapeHtml(props.text || '');
|
||||
const targetAttr = props.target === '_blank' ? ' target="_blank" rel="noopener noreferrer"' : '';
|
||||
return {
|
||||
html: `<a href="${escapeAttr(safeUrl(props.href || '#'))}"${targetAttr}${styleStr ? ` style="${styleStr}"` : ''}>${escapedText}</a>`,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,34 @@
|
||||
import { describe, test, expect } from 'vitest';
|
||||
import { Divider } from './Divider';
|
||||
|
||||
const toHtml = (Divider as any).toHtml;
|
||||
|
||||
describe('Divider.toHtml normal rendering', () => {
|
||||
test('renders thickness/color into the border-top style', () => {
|
||||
const { html } = toHtml({ thickness: '2px', color: '#ff0000' }, '');
|
||||
expect(html).toContain('border-top:2px solid #ff0000');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Divider.toHtml XSS hardening (thickness/color into style=)', () => {
|
||||
test('a thickness value with an attribute-breakout string cannot escape style=""', () => {
|
||||
const malicious = '1px" onmouseover="alert(1)';
|
||||
const { html } = toHtml({ thickness: malicious as any, color: '#000' }, '');
|
||||
// The quote must not survive unescaped -- otherwise it closes style=""
|
||||
// early and "onmouseover" becomes a live, attacker-controlled attribute.
|
||||
expect(html).not.toMatch(/"\s+onmouseover="/);
|
||||
expect(html).not.toMatch(/style="[^"]*"[^>]*onmouseover/);
|
||||
});
|
||||
|
||||
test('a color value with a </style><script> breakout is neutralized', () => {
|
||||
const malicious = '#000</style><script>alert(1)</script>';
|
||||
const { html } = toHtml({ thickness: '1px', color: malicious as any }, '');
|
||||
expect(html).not.toContain('<script>alert(1)</script>');
|
||||
});
|
||||
|
||||
test('a non-string thickness (object) does not raw-splice into style=""', () => {
|
||||
const malicious = { toString: () => '1px" onmouseover="alert(1)' };
|
||||
const { html } = toHtml({ thickness: malicious as any, color: '#000' }, '');
|
||||
expect(html).not.toMatch(/"\s+onmouseover="/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
import React, { CSSProperties } from 'react';
|
||||
import { useNode, UserComponent } from '@craftjs/core';
|
||||
import { cssPropsToString } from '../../utils/style-helpers';
|
||||
|
||||
interface DividerProps {
|
||||
color?: string;
|
||||
thickness?: string;
|
||||
style?: CSSProperties;
|
||||
}
|
||||
|
||||
export const Divider: UserComponent<DividerProps> = ({
|
||||
color = '#e4e4e7',
|
||||
thickness = '1px',
|
||||
style = {},
|
||||
}) => {
|
||||
const {
|
||||
connectors: { connect, drag },
|
||||
selected,
|
||||
} = useNode((node) => ({
|
||||
selected: node.events.selected,
|
||||
}));
|
||||
|
||||
return (
|
||||
<hr
|
||||
ref={(ref: HTMLHRElement | null): void => { if (ref) connect(drag(ref)); }}
|
||||
style={{
|
||||
border: 'none',
|
||||
borderTop: `${thickness} solid ${color}`,
|
||||
margin: '16px 0',
|
||||
outline: selected ? '2px solid #3b82f6' : 'none',
|
||||
...style,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
/* ---------- Craft config ---------- */
|
||||
|
||||
Divider.craft = {
|
||||
displayName: 'Divider',
|
||||
props: {
|
||||
color: '#e4e4e7',
|
||||
thickness: '1px',
|
||||
style: {},
|
||||
},
|
||||
rules: {
|
||||
canDrag: () => true,
|
||||
canMoveIn: () => false,
|
||||
canMoveOut: () => true,
|
||||
},
|
||||
};
|
||||
|
||||
/* ---------- HTML export ---------- */
|
||||
|
||||
(Divider as any).toHtml = (props: DividerProps, _childrenHtml: string) => {
|
||||
const styleStr = cssPropsToString({
|
||||
border: 'none',
|
||||
borderTop: `${props.thickness || '1px'} solid ${props.color || '#e4e4e7'}`,
|
||||
margin: '16px 0',
|
||||
...props.style,
|
||||
});
|
||||
return { html: `<hr${styleStr ? ` style="${styleStr}"` : ''} />` };
|
||||
};
|
||||
@@ -0,0 +1,105 @@
|
||||
import { describe, test, expect, vi, beforeEach } from 'vitest';
|
||||
import React from 'react';
|
||||
import { createRoot, Root } from 'react-dom/client';
|
||||
import { act } from 'react-dom/test-utils';
|
||||
|
||||
/* Footer only needs useNode from @craftjs/core. Mock it following the
|
||||
DOM-harness pattern in src/state/PageContext.slug.test.tsx (no
|
||||
@testing-library/react in this repo) so we can drive `selected` across
|
||||
re-renders and observe setProp calls without a real <Editor> tree. */
|
||||
let mockSelected = false;
|
||||
let lastCommittedProps: { text: string } = { text: '' };
|
||||
const setPropSpy = vi.fn((updater: (p: any) => void) => {
|
||||
updater(lastCommittedProps);
|
||||
});
|
||||
|
||||
vi.mock('@craftjs/core', () => ({
|
||||
useNode: (collect?: (node: any) => any) => {
|
||||
const node = { events: { selected: mockSelected } };
|
||||
return {
|
||||
connectors: { connect: (el: any) => el, drag: (el: any) => el },
|
||||
actions: { setProp: setPropSpy },
|
||||
...(collect ? collect(node) : {}),
|
||||
};
|
||||
},
|
||||
}));
|
||||
|
||||
import { Footer } from './Footer';
|
||||
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
|
||||
function render(ui: React.ReactElement) {
|
||||
container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
act(() => {
|
||||
root = createRoot(container);
|
||||
root.render(ui);
|
||||
});
|
||||
}
|
||||
|
||||
function rerender(ui: React.ReactElement) {
|
||||
act(() => {
|
||||
root.render(ui);
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
mockSelected = false;
|
||||
lastCommittedProps = { text: 'Original' };
|
||||
setPropSpy.mockClear();
|
||||
});
|
||||
|
||||
describe('Footer edit-guard (mirrors Heading.tsx mechanism)', () => {
|
||||
test('deselecting without a real blur still commits the in-progress edit', () => {
|
||||
mockSelected = true;
|
||||
render(<Footer text="Original" />);
|
||||
|
||||
const el = container.querySelector('footer')!;
|
||||
act(() => {
|
||||
el.innerText = 'Edited footer text';
|
||||
el.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
});
|
||||
|
||||
// No blur event fired -- simulate selection clearing (e.g. clicking
|
||||
// elsewhere) which is the scenario that used to lose the edit.
|
||||
mockSelected = false;
|
||||
rerender(<Footer text="Original" />);
|
||||
|
||||
expect(setPropSpy).toHaveBeenCalled();
|
||||
expect(lastCommittedProps.text).toBe('Edited footer text');
|
||||
|
||||
container.remove();
|
||||
});
|
||||
|
||||
test('a real blur still commits the edit (existing behavior preserved)', () => {
|
||||
mockSelected = true;
|
||||
render(<Footer text="Original" />);
|
||||
|
||||
const el = container.querySelector('footer')!;
|
||||
act(() => {
|
||||
el.innerText = 'Blurred edit';
|
||||
el.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
// React delegates onBlur via native 'focusout' (which bubbles) rather
|
||||
// than 'blur' (which doesn't) -- dispatch what React actually listens for.
|
||||
el.dispatchEvent(new FocusEvent('focusout', { bubbles: true }));
|
||||
});
|
||||
|
||||
expect(setPropSpy).toHaveBeenCalled();
|
||||
expect(lastCommittedProps.text).toBe('Blurred edit');
|
||||
|
||||
container.remove();
|
||||
});
|
||||
|
||||
test('deselecting with no edit made does not call setProp', () => {
|
||||
mockSelected = true;
|
||||
render(<Footer text="Original" />);
|
||||
|
||||
mockSelected = false;
|
||||
rerender(<Footer text="Original" />);
|
||||
|
||||
expect(setPropSpy).not.toHaveBeenCalled();
|
||||
|
||||
container.remove();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
import { describe, test, expect } from 'vitest';
|
||||
import { Footer } from './Footer';
|
||||
|
||||
const toHtml = (Footer as any).toHtml;
|
||||
|
||||
describe('Footer.toHtml text escaping (attacker-controlled `text` prop)', () => {
|
||||
test('a tag-breakout attempt in text is neutralized (no injected element)', () => {
|
||||
const { html } = toHtml({ text: '</footer><img src=x onerror=alert(1)>' }, '');
|
||||
expect(html).not.toContain('<img');
|
||||
expect(html).toContain('<img');
|
||||
});
|
||||
|
||||
test('ampersand is escaped for well-formed text content (consistency with escapeHtml)', () => {
|
||||
const { html } = toHtml({ text: 'Terms & Conditions' }, '');
|
||||
expect(html).toContain('Terms & Conditions');
|
||||
});
|
||||
|
||||
test('a normal copyright text value still renders unchanged', () => {
|
||||
const { html } = toHtml({ text: '© 2026 MySite. All rights reserved.' }, '');
|
||||
expect(html).toContain('© 2026 MySite. All rights reserved.');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,113 @@
|
||||
import React, { CSSProperties, useCallback, useRef, useEffect } from 'react';
|
||||
import { useNode, UserComponent } from '@craftjs/core';
|
||||
import { cssPropsToString } from '../../utils/style-helpers';
|
||||
import { escapeHtml } from '../../utils/escape';
|
||||
|
||||
interface FooterProps {
|
||||
text?: string;
|
||||
style?: CSSProperties;
|
||||
}
|
||||
|
||||
export const Footer: UserComponent<FooterProps> = ({
|
||||
text = '© 2026 MySite. All rights reserved.',
|
||||
style = {},
|
||||
}) => {
|
||||
const {
|
||||
connectors: { connect, drag },
|
||||
selected,
|
||||
actions: { setProp },
|
||||
} = useNode((node) => ({
|
||||
selected: node.events.selected,
|
||||
}));
|
||||
|
||||
const elRef = useRef<HTMLElement | null>(null);
|
||||
const editedTextRef = useRef<string | null>(null);
|
||||
|
||||
const commitText = useCallback(() => {
|
||||
if (elRef.current) {
|
||||
const newText = elRef.current.innerText;
|
||||
editedTextRef.current = newText;
|
||||
setProp((p: FooterProps) => { p.text = newText; }, 500);
|
||||
}
|
||||
}, [setProp]);
|
||||
|
||||
// Commit on blur
|
||||
const handleBlur = useCallback(() => { commitText(); }, [commitText]);
|
||||
|
||||
// Also commit on deselect via effect -- covers the case where selection
|
||||
// clears without a real blur (e.g. clicking a different element that
|
||||
// steals selection programmatically), which used to lose the in-progress
|
||||
// edit. Mirrors Heading.tsx's mechanism.
|
||||
useEffect(() => {
|
||||
if (!selected && editedTextRef.current !== null) {
|
||||
setProp((p: FooterProps) => { p.text = editedTextRef.current!; }, 500);
|
||||
editedTextRef.current = null;
|
||||
}
|
||||
}, [selected, setProp]);
|
||||
|
||||
// Set DOM text on mount and when text prop changes externally (not during editing)
|
||||
useEffect(() => {
|
||||
if (elRef.current && !selected && editedTextRef.current === null) {
|
||||
elRef.current.innerText = text || '';
|
||||
}
|
||||
}, [text, selected]);
|
||||
|
||||
return (
|
||||
<footer
|
||||
ref={(ref: HTMLElement | null): void => {
|
||||
elRef.current = ref;
|
||||
if (ref) connect(drag(ref));
|
||||
}}
|
||||
contentEditable={selected}
|
||||
suppressContentEditableWarning
|
||||
onBlur={handleBlur}
|
||||
onInput={() => {
|
||||
// Track that we have unsaved edits
|
||||
if (elRef.current) {
|
||||
editedTextRef.current = elRef.current.innerText;
|
||||
}
|
||||
}}
|
||||
style={{
|
||||
padding: '24px 20px',
|
||||
textAlign: 'center',
|
||||
outline: 'none',
|
||||
cursor: selected ? 'text' : 'pointer',
|
||||
...style,
|
||||
}}
|
||||
>
|
||||
{selected ? undefined : (text || '')}
|
||||
</footer>
|
||||
);
|
||||
};
|
||||
|
||||
/* ---------- Craft config ---------- */
|
||||
|
||||
Footer.craft = {
|
||||
displayName: 'Footer',
|
||||
props: {
|
||||
text: '© 2026 MySite. All rights reserved.',
|
||||
style: {
|
||||
backgroundColor: '#18181b',
|
||||
color: '#a1a1aa',
|
||||
fontSize: '14px',
|
||||
padding: '24px 20px',
|
||||
},
|
||||
},
|
||||
rules: {
|
||||
canDrag: () => true,
|
||||
canMoveIn: () => false,
|
||||
canMoveOut: () => true,
|
||||
},
|
||||
};
|
||||
|
||||
/* ---------- HTML export ---------- */
|
||||
|
||||
(Footer as any).toHtml = (props: FooterProps, _childrenHtml: string) => {
|
||||
const styleStr = cssPropsToString({
|
||||
padding: '24px 20px',
|
||||
textAlign: 'center',
|
||||
...props.style,
|
||||
});
|
||||
const escapedText = escapeHtml(props.text || '');
|
||||
return { html: `<footer${styleStr ? ` style="${styleStr}"` : ''}>${escapedText}</footer>` };
|
||||
};
|
||||
@@ -0,0 +1,58 @@
|
||||
import { describe, test, expect } from 'vitest';
|
||||
import { Heading } from './Heading';
|
||||
|
||||
const toHtml = (Heading as any).toHtml;
|
||||
|
||||
describe('Heading.toHtml level allowlist (adversarial re-review, same class as C1)', () => {
|
||||
test('a malicious level value clamps to h2 -- no injected <img>, no broken-out tag', () => {
|
||||
const { html } = toHtml({ text: 'x', level: 'h2><img src=x onerror=alert(1)' }, '');
|
||||
expect(html).not.toContain('<img');
|
||||
expect(html).not.toContain('onerror');
|
||||
expect(html.startsWith('<h2')).toBe(true);
|
||||
expect(html.endsWith('</h2>')).toBe(true);
|
||||
});
|
||||
|
||||
test('a numeric out-of-range level (99) clamps to h2', () => {
|
||||
const { html } = toHtml({ text: 'x', level: 99 as any }, '');
|
||||
expect(html.startsWith('<h2')).toBe(true);
|
||||
expect(html.endsWith('</h2>')).toBe(true);
|
||||
});
|
||||
|
||||
test('a non-heading string level clamps to h2', () => {
|
||||
const { html } = toHtml({ text: 'x', level: 'script' as any }, '');
|
||||
expect(html.startsWith('<h2')).toBe(true);
|
||||
expect(html).not.toContain('<script');
|
||||
});
|
||||
|
||||
test('a normal valid level (h4) still emits <h4', () => {
|
||||
const { html } = toHtml({ text: 'x', level: 'h4' }, '');
|
||||
expect(html).toContain('<h4');
|
||||
expect(html).toContain('</h4>');
|
||||
});
|
||||
|
||||
test('all valid levels h1-h6 still work', () => {
|
||||
for (const level of ['h1', 'h2', 'h3', 'h4', 'h5', 'h6']) {
|
||||
const { html } = toHtml({ text: 'x', level }, '');
|
||||
expect(html.startsWith(`<${level}`)).toBe(true);
|
||||
expect(html.endsWith(`</${level}>`)).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Heading.toHtml text escaping (attacker-controlled `text` prop)', () => {
|
||||
test('a tag-breakout attempt in text is neutralized (no injected element)', () => {
|
||||
const { html } = toHtml({ text: '</h2><img src=x onerror=alert(1)>', level: 'h2' }, '');
|
||||
expect(html).not.toContain('<img');
|
||||
expect(html).toContain('<img');
|
||||
});
|
||||
|
||||
test('ampersand is escaped for well-formed text content (consistency with escapeHtml)', () => {
|
||||
const { html } = toHtml({ text: 'Fish & Chips', level: 'h2' }, '');
|
||||
expect(html).toContain('Fish & Chips');
|
||||
});
|
||||
|
||||
test('a normal text value still renders unchanged', () => {
|
||||
const { html } = toHtml({ text: 'Hello world', level: 'h2' }, '');
|
||||
expect(html).toBe('<h2>Hello world</h2>');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,117 @@
|
||||
import React, { CSSProperties, useCallback, useRef, useEffect } from 'react';
|
||||
import { useNode, UserComponent } from '@craftjs/core';
|
||||
import { cssPropsToString } from '../../utils/style-helpers';
|
||||
import { escapeHtml } from '../../utils/escape';
|
||||
|
||||
type HeadingLevel = 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6';
|
||||
|
||||
// `level` is settable via the AI `update_props` path and from deserialized
|
||||
// saved state -- neither type-checked at runtime -- and is interpolated
|
||||
// directly into the tag position (`React.createElement(level, ...)` /
|
||||
// `<${tag}` in `toHtml`). A malicious value like `h2><img src=x
|
||||
// onerror=alert(1)` (or a non-h1-6 string) must never reach that position
|
||||
// unchecked. Anything not in this allowlist clamps to `'h2'`.
|
||||
const ALLOWED_HEADING_LEVELS = ['h1', 'h2', 'h3', 'h4', 'h5', 'h6'] as const;
|
||||
const sanitizeHeadingLevel = (level: unknown): HeadingLevel =>
|
||||
(ALLOWED_HEADING_LEVELS as readonly unknown[]).includes(level) ? (level as HeadingLevel) : 'h2';
|
||||
|
||||
interface HeadingProps {
|
||||
text?: string;
|
||||
level?: HeadingLevel;
|
||||
style?: CSSProperties;
|
||||
cssId?: string;
|
||||
cssClass?: string;
|
||||
hideOnDesktop?: boolean;
|
||||
hideOnTablet?: boolean;
|
||||
hideOnMobile?: boolean;
|
||||
animation?: string;
|
||||
animationDelay?: string;
|
||||
}
|
||||
|
||||
export const Heading: UserComponent<HeadingProps> = ({
|
||||
text = 'Heading',
|
||||
level = 'h2',
|
||||
style = {},
|
||||
}) => {
|
||||
const {
|
||||
connectors: { connect, drag },
|
||||
selected,
|
||||
actions: { setProp },
|
||||
} = useNode((node) => ({
|
||||
selected: node.events.selected,
|
||||
}));
|
||||
|
||||
const safeLevel = sanitizeHeadingLevel(level);
|
||||
const elRef = useRef<HTMLElement | null>(null);
|
||||
const editedTextRef = useRef<string | null>(null);
|
||||
|
||||
const commitText = useCallback(() => {
|
||||
if (elRef.current) {
|
||||
const newText = elRef.current.innerText;
|
||||
editedTextRef.current = newText;
|
||||
setProp((p: HeadingProps) => { p.text = newText; });
|
||||
}
|
||||
}, [setProp]);
|
||||
|
||||
// Commit on blur
|
||||
const handleBlur = useCallback(() => { commitText(); }, [commitText]);
|
||||
|
||||
// Also commit on deselect via effect
|
||||
useEffect(() => {
|
||||
if (!selected && editedTextRef.current !== null) {
|
||||
setProp((p: HeadingProps) => { p.text = editedTextRef.current!; });
|
||||
editedTextRef.current = null;
|
||||
}
|
||||
}, [selected, setProp]);
|
||||
|
||||
// Set DOM text on mount and when text prop changes externally (not during editing)
|
||||
useEffect(() => {
|
||||
if (elRef.current && !selected && editedTextRef.current === null) {
|
||||
elRef.current.innerText = text || '';
|
||||
}
|
||||
}, [text, selected]);
|
||||
|
||||
return React.createElement(safeLevel, {
|
||||
ref: (ref: HTMLElement | null): void => {
|
||||
elRef.current = ref;
|
||||
if (ref) connect(drag(ref));
|
||||
},
|
||||
contentEditable: selected,
|
||||
suppressContentEditableWarning: true,
|
||||
onBlur: handleBlur,
|
||||
onInput: () => {
|
||||
// Track that we have unsaved edits
|
||||
if (elRef.current) {
|
||||
editedTextRef.current = elRef.current.innerText;
|
||||
}
|
||||
},
|
||||
style: { outline: 'none', cursor: selected ? 'text' : 'pointer', minHeight: '1em', ...style },
|
||||
});
|
||||
};
|
||||
|
||||
Heading.craft = {
|
||||
displayName: 'Heading',
|
||||
props: {
|
||||
text: 'Your Heading',
|
||||
level: 'h2' as HeadingLevel,
|
||||
style: {
|
||||
fontSize: '36px',
|
||||
fontWeight: '700',
|
||||
fontFamily: 'Inter, sans-serif',
|
||||
color: '#1f2937',
|
||||
marginBottom: '16px',
|
||||
},
|
||||
},
|
||||
rules: {
|
||||
canDrag: () => true,
|
||||
canMoveIn: () => false,
|
||||
canMoveOut: () => true,
|
||||
},
|
||||
};
|
||||
|
||||
(Heading as any).toHtml = (props: HeadingProps, _childrenHtml: string) => {
|
||||
const tag = sanitizeHeadingLevel(props.level);
|
||||
const safeText = escapeHtml(props.text || '');
|
||||
const styleStr = cssPropsToString(props.style);
|
||||
return { html: `<${tag}${styleStr ? ` style="${styleStr}"` : ''}>${safeText}</${tag}>` };
|
||||
};
|
||||
@@ -0,0 +1,66 @@
|
||||
import { describe, test, expect } from 'vitest';
|
||||
import { purifyHtml } from './HtmlBlock';
|
||||
|
||||
describe('purifyHtml', () => {
|
||||
test('strips script tags', () => {
|
||||
expect(purifyHtml('<p>ok</p><script>alert(1)</script>')).not.toContain('<script');
|
||||
});
|
||||
test('strips on-event handlers', () => {
|
||||
const out = purifyHtml('<a onclick="bad()" href="/x">x</a>');
|
||||
expect(out).not.toContain('onclick');
|
||||
expect(out).toContain('href="/x"');
|
||||
});
|
||||
test('blocks javascript: URLs', () => {
|
||||
expect(purifyHtml('<a href="javascript:void(0)">x</a>')).not.toContain('javascript:');
|
||||
});
|
||||
test('allows YouTube iframe', () => {
|
||||
const out = purifyHtml('<iframe src="https://www.youtube.com/embed/abc" allowfullscreen></iframe>');
|
||||
expect(out).toContain('youtube.com/embed/abc');
|
||||
});
|
||||
test('strips form/input', () => {
|
||||
expect(purifyHtml('<form><input name="x"></form>')).not.toContain('<form');
|
||||
});
|
||||
});
|
||||
|
||||
describe('purifyHtml iframe sandboxing (M-6)', () => {
|
||||
test('forces a restrictive sandbox attribute onto every iframe', () => {
|
||||
const out = purifyHtml('<iframe src="https://example.com/"></iframe>');
|
||||
expect(out).toMatch(/<iframe[^>]*\bsandbox="[^"]+"/);
|
||||
});
|
||||
|
||||
test('sandbox value omits allow-top-navigation (no top-level nav escape)', () => {
|
||||
const out = purifyHtml('<iframe src="https://example.com/"></iframe>');
|
||||
const sandbox = out.match(/sandbox="([^"]*)"/)![1];
|
||||
expect(sandbox).not.toMatch(/allow-top-navigation/);
|
||||
});
|
||||
|
||||
test('legitimate embeds (YouTube) still work and get sandboxed too', () => {
|
||||
const out = purifyHtml('<iframe src="https://www.youtube.com/embed/abc" allowfullscreen></iframe>');
|
||||
expect(out).toContain('youtube.com/embed/abc');
|
||||
expect(out).toMatch(/<iframe[^>]*\bsandbox="[^"]+"/);
|
||||
});
|
||||
|
||||
test('adds referrerpolicy=no-referrer to iframes', () => {
|
||||
const out = purifyHtml('<iframe src="https://example.com/"></iframe>');
|
||||
expect(out).toContain('referrerpolicy="no-referrer"');
|
||||
});
|
||||
|
||||
test('script/on* attributes are still stripped alongside the sandboxed iframe', () => {
|
||||
const out = purifyHtml('<iframe src="https://example.com/" onload="alert(1)"></iframe><script>alert(2)</script>');
|
||||
expect(out).not.toContain('onload');
|
||||
expect(out).not.toContain('<script');
|
||||
});
|
||||
|
||||
test('repeated calls do not leak/accumulate the hook (no duplicate sandbox attr, no cross-call state)', () => {
|
||||
purifyHtml('<iframe src="https://a.example/"></iframe>');
|
||||
purifyHtml('<iframe src="https://b.example/"></iframe>');
|
||||
const out = purifyHtml('<iframe src="https://c.example/"></iframe>');
|
||||
const sandboxMatches = out.match(/sandbox="/g) || [];
|
||||
expect(sandboxMatches.length).toBe(1);
|
||||
});
|
||||
|
||||
test('a non-iframe element sanitized alongside an iframe is not touched by the hook', () => {
|
||||
const out = purifyHtml('<p>hi</p><iframe src="https://example.com/"></iframe>');
|
||||
expect(out).toContain('<p>hi</p>');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
import { describe, test, expect } from 'vitest';
|
||||
import { HtmlBlock } from './HtmlBlock';
|
||||
|
||||
const toHtml = (HtmlBlock as any).toHtml;
|
||||
|
||||
describe('HtmlBlock.toHtml sanitizes raw code (A4.1)', () => {
|
||||
test('strips <script> and on-handlers from exported output', () => {
|
||||
const { html } = toHtml({ code: '<script>alert(1)</script><p onclick="x">hi</p>' }, '');
|
||||
expect(html).not.toContain('<script');
|
||||
expect(html).not.toContain('onclick');
|
||||
expect(html).toContain('<p>hi</p>');
|
||||
});
|
||||
|
||||
test('does not wrap output in an unsanitized element carrying the style prop raw', () => {
|
||||
// toHtml only ever returns the sanitized `code` blob -- there is no
|
||||
// wrapper <div style="..."> in the exported HTML, so a malicious
|
||||
// `style` prop (e.g. an attacker-controlled object with a breakout
|
||||
// toString()) has nothing to splice into.
|
||||
const malicious = { toString: () => 'color:red" onmouseover="alert(1)' } as any;
|
||||
const { html } = toHtml({ code: '<p>hi</p>', style: malicious }, '');
|
||||
expect(html).not.toMatch(/onmouseover/);
|
||||
expect(html).not.toMatch(/<div/);
|
||||
expect(html).toBe('<p>hi</p>');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,101 @@
|
||||
import React, { CSSProperties, useMemo } from 'react';
|
||||
import { useNode, UserComponent } from '@craftjs/core';
|
||||
import DOMPurify from 'dompurify';
|
||||
|
||||
interface HtmlBlockProps {
|
||||
code: string;
|
||||
style?: CSSProperties;
|
||||
aiName?: string;
|
||||
node_id?: string;
|
||||
}
|
||||
|
||||
const PURIFY_CONFIG = {
|
||||
ALLOWED_TAGS: [
|
||||
'a','p','br','hr','div','span','section','article',
|
||||
'header','footer','main','aside','nav',
|
||||
'ul','ol','li',
|
||||
'h1','h2','h3','h4','h5','h6',
|
||||
'em','strong','b','i','u','s',
|
||||
'blockquote','code','pre',
|
||||
'img','figure','figcaption',
|
||||
'iframe',
|
||||
],
|
||||
ALLOWED_ATTR: [
|
||||
'href','src','alt','title','target','rel',
|
||||
'width','height','class',
|
||||
'allowfullscreen','allow','frameborder',
|
||||
'sandbox','referrerpolicy',
|
||||
],
|
||||
ALLOWED_URI_REGEXP: /^(?:(?:https?|mailto|tel|data:image\/[a-z]+;base64,):|[^a-z]|[a-z+.-]+(?:[^a-z+.\-:]|$))/i,
|
||||
FORBID_TAGS: ['script','style','object','embed','link','meta','form','input','button','select','textarea'],
|
||||
FORBID_ATTR: [/^on/i],
|
||||
};
|
||||
|
||||
// M-6: `<iframe>` is allowed (maps/video embeds are a legitimate use case)
|
||||
// but an iframe with a `src` and NO `sandbox` attribute is a clickjacking/
|
||||
// phishing vector (DOMPurify already strips <script>/on*=, but an
|
||||
// unsandboxed iframe still gets full script execution, same-origin-ish
|
||||
// access via document.domain tricks, top-level navigation, etc., inside
|
||||
// itself). This hook force-sets a restrictive sandbox on every iframe that
|
||||
// survives sanitization, keeping `allow-scripts`/`allow-same-origin`/
|
||||
// `allow-popups`/`allow-forms` (needed for interactive maps/video/oauth
|
||||
// popups) but deliberately omitting `allow-top-navigation` so an embedded
|
||||
// page can never redirect/hijack the parent tab.
|
||||
const IFRAME_SANDBOX_HOOK = (node: Element): void => {
|
||||
if (node.nodeName === 'IFRAME') {
|
||||
node.setAttribute('sandbox', 'allow-scripts allow-same-origin allow-popups allow-forms');
|
||||
node.setAttribute('referrerpolicy', 'no-referrer');
|
||||
}
|
||||
};
|
||||
|
||||
export function purifyHtml(input: string): string {
|
||||
// Hook is added immediately before sanitize() and removed immediately
|
||||
// after, scoped tightly to this single call -- so it can never leak onto
|
||||
// (or accumulate duplicate copies across) any other DOMPurify.sanitize()
|
||||
// call elsewhere in the app, and repeated purifyHtml() calls never stack
|
||||
// multiple copies of the same hook.
|
||||
DOMPurify.addHook('afterSanitizeAttributes', IFRAME_SANDBOX_HOOK);
|
||||
try {
|
||||
return DOMPurify.sanitize(input || '', PURIFY_CONFIG as any) as unknown as string;
|
||||
} finally {
|
||||
DOMPurify.removeHook('afterSanitizeAttributes', IFRAME_SANDBOX_HOOK as any);
|
||||
}
|
||||
}
|
||||
|
||||
export const HtmlBlock: UserComponent<HtmlBlockProps> = ({ code = '', style = {} }) => {
|
||||
const { connectors: { connect, drag }, selected } = useNode((node) => ({ selected: node.events.selected }));
|
||||
const clean = useMemo(() => purifyHtml(code), [code]);
|
||||
const setRef = (ref: HTMLElement | null): void => { if (ref) connect(drag(ref)); };
|
||||
return React.createElement('div', {
|
||||
ref: setRef,
|
||||
style: {
|
||||
minHeight: '40px',
|
||||
outline: selected ? '2px solid #3b82f6' : 'none',
|
||||
...style,
|
||||
},
|
||||
dangerouslySetInnerHTML: { __html: clean },
|
||||
});
|
||||
};
|
||||
|
||||
/* ---------- Craft config ---------- */
|
||||
|
||||
HtmlBlock.craft = {
|
||||
displayName: 'HTML',
|
||||
props: {
|
||||
code: '',
|
||||
style: {},
|
||||
},
|
||||
rules: {
|
||||
canDrag: () => true,
|
||||
canMoveIn: () => false,
|
||||
canMoveOut: () => true,
|
||||
},
|
||||
};
|
||||
|
||||
/* ---------- HTML export ---------- */
|
||||
|
||||
(HtmlBlock as any).toHtml = (props: HtmlBlockProps, _childrenHtml: string) => {
|
||||
// Run through the same DOMPurify config used for the live editor preview
|
||||
// so exported pages can't carry <script>/on*= payloads either.
|
||||
return { html: purifyHtml(props.code || '') };
|
||||
};
|
||||
@@ -0,0 +1,72 @@
|
||||
import { describe, test, expect } from 'vitest';
|
||||
import { Icon } from './Icon';
|
||||
|
||||
const toHtml = (Icon as any).toHtml;
|
||||
|
||||
describe('Icon.toHtml normal rendering', () => {
|
||||
test('renders icon class, size/color style, and link href', () => {
|
||||
const { html } = toHtml({ icon: 'fa-star', size: '32px', color: '#3b82f6', link: 'https://example.com' }, '');
|
||||
expect(html).toContain('class="fa fa-star"');
|
||||
expect(html).toContain('font-size:32px');
|
||||
expect(html).toContain('color:#3b82f6');
|
||||
expect(html).toContain('href="https://example.com"');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Icon.toHtml XSS hardening', () => {
|
||||
test('an icon name with an attribute-breakout string is escaped, not raw-concatenated', () => {
|
||||
const malicious = 'star"><script>alert(1)</script>';
|
||||
const { html } = toHtml({ icon: malicious as any }, '');
|
||||
expect(html).not.toContain('<script>alert(1)</script>');
|
||||
expect(html).not.toMatch(/class="fa star"><script>/);
|
||||
});
|
||||
|
||||
test('a size value with an attribute-breakout string cannot escape style=""', () => {
|
||||
const malicious = '24px" onerror="alert(1)';
|
||||
const { html } = toHtml({ size: malicious as any }, '');
|
||||
expect(html).not.toMatch(/"\s+onerror="/);
|
||||
});
|
||||
|
||||
test('a bgSize/bgColor breakout via background wrapper is neutralized', () => {
|
||||
const malicious = '56px" onmouseover="alert(1)';
|
||||
const { html } = toHtml({ bgShape: 'circle', bgColor: '#fff', bgSize: malicious as any }, '');
|
||||
expect(html).not.toMatch(/"\s+onmouseover="/);
|
||||
});
|
||||
|
||||
test('a javascript: link is neutralized to an empty href', () => {
|
||||
const { html } = toHtml({ link: 'javascript:alert(1)' }, '');
|
||||
expect(html).not.toContain('javascript:alert(1)');
|
||||
});
|
||||
|
||||
test('a link value with an attribute-breakout string cannot escape href=""', () => {
|
||||
const malicious = 'https://example.com" onclick="alert(1)';
|
||||
const { html } = toHtml({ link: malicious as any }, '');
|
||||
expect(html).not.toMatch(/"\s+onclick="/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Icon.toHtml bgShape/bgColor/bgSize rendering (built but, until this panel update, unexposed)', () => {
|
||||
test('bgShape="circle" + bgColor render a colored 50%-radius background box', () => {
|
||||
const { html } = toHtml({ icon: 'fa-star', bgShape: 'circle', bgColor: '#3b82f6', bgSize: '64px' }, '');
|
||||
expect(html).toContain('background-color:#3b82f6');
|
||||
expect(html).toContain('border-radius:50%');
|
||||
expect(html).toContain('width:64px');
|
||||
expect(html).toContain('height:64px');
|
||||
});
|
||||
|
||||
test('bgShape="none" (default) renders the bare icon with no background wrapper', () => {
|
||||
const { html } = toHtml({ icon: 'fa-star' }, '');
|
||||
expect(html).not.toContain('background-color');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Icon.craft.props includes the box-model/animation/visibility rollout props', () => {
|
||||
test('animation, animationDelay, and all 3 hideOn* flags are declared (blank/false defaults)', () => {
|
||||
const props = (Icon as any).craft.props;
|
||||
expect(props).toHaveProperty('animation', '');
|
||||
expect(props).toHaveProperty('animationDelay', '');
|
||||
expect(props).toHaveProperty('hideOnDesktop', false);
|
||||
expect(props).toHaveProperty('hideOnTablet', false);
|
||||
expect(props).toHaveProperty('hideOnMobile', false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,154 @@
|
||||
import React, { CSSProperties } from 'react';
|
||||
import { useNode, UserComponent } from '@craftjs/core';
|
||||
import { cssPropsToString } from '../../utils/style-helpers';
|
||||
import { escapeAttr, safeUrl } from '../../utils/escape';
|
||||
|
||||
interface IconProps {
|
||||
icon?: string;
|
||||
size?: string;
|
||||
color?: string;
|
||||
bgColor?: string;
|
||||
bgShape?: 'none' | 'circle' | 'square' | 'rounded';
|
||||
bgSize?: string;
|
||||
link?: string;
|
||||
style?: CSSProperties;
|
||||
animation?: string;
|
||||
animationDelay?: string;
|
||||
hideOnDesktop?: boolean;
|
||||
hideOnTablet?: boolean;
|
||||
hideOnMobile?: boolean;
|
||||
}
|
||||
|
||||
function getBgBorderRadius(shape: string): string {
|
||||
if (shape === 'circle') return '50%';
|
||||
if (shape === 'rounded') return '8px';
|
||||
if (shape === 'square') return '0px';
|
||||
return '0px';
|
||||
}
|
||||
|
||||
export const Icon: UserComponent<IconProps> = ({
|
||||
icon = 'fa-star',
|
||||
size = '32px',
|
||||
color = '#3b82f6',
|
||||
bgColor = 'transparent',
|
||||
bgShape = 'none',
|
||||
bgSize = '56px',
|
||||
link = '',
|
||||
style = {},
|
||||
}) => {
|
||||
const {
|
||||
connectors: { connect, drag },
|
||||
selected,
|
||||
} = useNode((node) => ({
|
||||
selected: node.events.selected,
|
||||
}));
|
||||
|
||||
const iconEl = (
|
||||
<i
|
||||
className={`fa ${icon}`}
|
||||
style={{ fontSize: size, color, lineHeight: 1 }}
|
||||
/>
|
||||
);
|
||||
|
||||
const hasBg = bgShape !== 'none' && bgColor !== 'transparent';
|
||||
|
||||
const wrapperEl = hasBg ? (
|
||||
<div
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
width: bgSize,
|
||||
height: bgSize,
|
||||
backgroundColor: bgColor,
|
||||
borderRadius: getBgBorderRadius(bgShape || 'none'),
|
||||
}}
|
||||
>
|
||||
{iconEl}
|
||||
</div>
|
||||
) : iconEl;
|
||||
|
||||
const content = link ? (
|
||||
<a href={link} onClick={(e) => e.preventDefault()} style={{ textDecoration: 'none', color: 'inherit' }}>
|
||||
{wrapperEl}
|
||||
</a>
|
||||
) : wrapperEl;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={(ref: HTMLDivElement | null): void => { if (ref) connect(drag(ref)); }}
|
||||
style={{
|
||||
display: 'inline-block',
|
||||
outline: selected ? '2px solid #3b82f6' : 'none',
|
||||
...style,
|
||||
}}
|
||||
>
|
||||
{content}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/* ---------- Craft config ---------- */
|
||||
|
||||
Icon.craft = {
|
||||
displayName: 'Icon',
|
||||
props: {
|
||||
icon: 'fa-star',
|
||||
size: '32px',
|
||||
color: '#3b82f6',
|
||||
bgColor: 'transparent',
|
||||
bgShape: 'none',
|
||||
bgSize: '56px',
|
||||
link: '',
|
||||
style: {},
|
||||
animation: '',
|
||||
animationDelay: '',
|
||||
hideOnDesktop: false,
|
||||
hideOnTablet: false,
|
||||
hideOnMobile: false,
|
||||
},
|
||||
rules: {
|
||||
canDrag: () => true,
|
||||
canMoveIn: () => false,
|
||||
canMoveOut: () => true,
|
||||
},
|
||||
};
|
||||
|
||||
/* ---------- HTML export ---------- */
|
||||
|
||||
(Icon as any).toHtml = (props: IconProps, _childrenHtml: string) => {
|
||||
const {
|
||||
icon = 'fa-star',
|
||||
size = '32px',
|
||||
color = '#3b82f6',
|
||||
bgColor = 'transparent',
|
||||
bgShape = 'none',
|
||||
bgSize = '56px',
|
||||
link = '',
|
||||
style = {},
|
||||
} = props;
|
||||
|
||||
const iconStyle = cssPropsToString({ fontSize: size, color, lineHeight: '1' });
|
||||
let iconHtml = `<i class="fa ${escapeAttr(icon)}"${iconStyle ? ` style="${iconStyle}"` : ''}></i>`;
|
||||
|
||||
const hasBg = bgShape !== 'none' && bgColor !== 'transparent';
|
||||
if (hasBg) {
|
||||
const bgStyle = cssPropsToString({
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
width: bgSize,
|
||||
height: bgSize,
|
||||
backgroundColor: bgColor,
|
||||
borderRadius: getBgBorderRadius(bgShape || 'none'),
|
||||
});
|
||||
iconHtml = `<div${bgStyle ? ` style="${bgStyle}"` : ''}>${iconHtml}</div>`;
|
||||
}
|
||||
|
||||
if (link) {
|
||||
iconHtml = `<a href="${escapeAttr(safeUrl(link))}" style="text-decoration:none;color:inherit">${iconHtml}</a>`;
|
||||
}
|
||||
|
||||
const wrapperStyle = cssPropsToString({ display: 'inline-block', ...style });
|
||||
return { html: `<div${wrapperStyle ? ` style="${wrapperStyle}"` : ''}>${iconHtml}</div>` };
|
||||
};
|
||||
@@ -0,0 +1,62 @@
|
||||
import { describe, test, expect } from 'vitest';
|
||||
import { Logo } from './Logo';
|
||||
|
||||
/*
|
||||
* Regression coverage for Logo.toHtml -- audited during the toHtml
|
||||
* attribute-XSS sweep (see task-cssxss-brief.md) and found already fully
|
||||
* sanitized (href/src via escapeAttr(safeUrl()), alt/text via escapeAttr /
|
||||
* escapeHtml, imageWidth/fontSize/etc. routed through cssPropsToString which
|
||||
* sanitizes every value regardless of declared type). No fix was required;
|
||||
* these tests lock that behavior in against regressions.
|
||||
*/
|
||||
const toHtml = (Logo as any).toHtml;
|
||||
|
||||
describe('Logo.toHtml href sanitization (attacker-controlled `href` prop)', () => {
|
||||
test('a javascript: URL is neutralized', () => {
|
||||
const { html } = toHtml({ href: 'javascript:alert(1)' }, '');
|
||||
expect(html).not.toContain('javascript:alert');
|
||||
});
|
||||
|
||||
test('a quote-breakout href does not escape the anchor attribute', () => {
|
||||
const malicious = '"><script>alert(1)</script>';
|
||||
const { html } = toHtml({ href: malicious }, '');
|
||||
expect(html).not.toContain('<script>alert(1)</script>');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Logo.toHtml image src/alt sanitization (type="image")', () => {
|
||||
test('a javascript: imageSrc is neutralized', () => {
|
||||
const { html } = toHtml({ type: 'image', imageSrc: 'javascript:alert(1)', text: 'Logo' }, '');
|
||||
expect(html).not.toContain('javascript:alert');
|
||||
});
|
||||
|
||||
test('a quote-breakout alt (from `text`) does not escape the img attribute', () => {
|
||||
const malicious = '"><script>alert(1)</script>';
|
||||
const { html } = toHtml({ type: 'image', imageSrc: 'https://example.com/logo.png', text: malicious }, '');
|
||||
expect(html).not.toContain('<script>alert(1)</script>');
|
||||
});
|
||||
|
||||
test('a non-numeric imageWidth (attribute-breakout attempt) does not escape the style attribute', () => {
|
||||
const malicious = '1"><script>alert(1)</script>';
|
||||
const { html } = toHtml({ type: 'image', imageSrc: 'https://example.com/logo.png', imageWidth: malicious }, '');
|
||||
expect(html).not.toContain('<script>alert(1)</script>');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Logo.toHtml text-logo styling sanitization', () => {
|
||||
test('a quote-breakout color does not escape the span style attribute', () => {
|
||||
const malicious = 'red" onmouseover="alert(1)';
|
||||
const { html } = toHtml({ type: 'text', text: 'MySite', color: malicious }, '');
|
||||
// The raw `"` must never survive un-escaped inside the style attribute
|
||||
// value -- if it did, `onmouseover` would land as a REAL new HTML
|
||||
// attribute (breakout) rather than being inert CSS-value garbage inside
|
||||
// a properly-escaped style="...".
|
||||
expect(html).not.toMatch(/style="[^"]*"[^>]*onmouseover/);
|
||||
});
|
||||
|
||||
test('a normal logo renders as expected', () => {
|
||||
const { html } = toHtml({ type: 'text', text: 'MySite', href: '/' }, '');
|
||||
expect(html).toContain('href="/"');
|
||||
expect(html).toContain('MySite');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,129 @@
|
||||
import React, { CSSProperties } from 'react';
|
||||
import { useNode, UserComponent } from '@craftjs/core';
|
||||
import { cssPropsToString } from '../../utils/style-helpers';
|
||||
import { useSiteDesign } from '../../state/SiteDesignContext';
|
||||
import { escapeHtml, escapeAttr, safeUrl, safeImageUrl } from '../../utils/escape';
|
||||
|
||||
/* ---------- Types ---------- */
|
||||
|
||||
interface LogoProps {
|
||||
type?: 'text' | 'image';
|
||||
text?: string;
|
||||
imageSrc?: string;
|
||||
imageWidth?: string;
|
||||
href?: string;
|
||||
fontFamily?: string;
|
||||
fontSize?: string;
|
||||
fontWeight?: string;
|
||||
color?: string;
|
||||
style?: CSSProperties;
|
||||
}
|
||||
|
||||
/* ---------- Component ---------- */
|
||||
|
||||
export const Logo: UserComponent<LogoProps> = ({
|
||||
type = 'text',
|
||||
text = 'MySite',
|
||||
imageSrc = '',
|
||||
imageWidth = '120px',
|
||||
href = '/',
|
||||
fontFamily = 'Inter, sans-serif',
|
||||
fontSize = '20px',
|
||||
fontWeight = '700',
|
||||
color,
|
||||
style = {},
|
||||
}) => {
|
||||
const {
|
||||
connectors: { connect, drag },
|
||||
} = useNode();
|
||||
|
||||
const { design } = useSiteDesign();
|
||||
const resolvedColor = color || design.textColor;
|
||||
|
||||
return (
|
||||
<a
|
||||
ref={(ref: HTMLElement | null): void => { if (ref) connect(drag(ref)); }}
|
||||
href={href}
|
||||
onClick={(e) => e.preventDefault()}
|
||||
style={{
|
||||
textDecoration: 'none',
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
flexShrink: 0,
|
||||
...style,
|
||||
}}
|
||||
>
|
||||
{type === 'image' && imageSrc ? (
|
||||
<img
|
||||
src={imageSrc}
|
||||
alt={text || 'Logo'}
|
||||
style={{ width: imageWidth, height: 'auto', display: 'block' }}
|
||||
/>
|
||||
) : (
|
||||
<span style={{
|
||||
fontWeight,
|
||||
fontSize,
|
||||
fontFamily,
|
||||
color: resolvedColor,
|
||||
}}>
|
||||
{text}
|
||||
</span>
|
||||
)}
|
||||
</a>
|
||||
);
|
||||
};
|
||||
|
||||
/* ---------- Craft config ---------- */
|
||||
|
||||
Logo.craft = {
|
||||
displayName: 'Logo',
|
||||
props: {
|
||||
type: 'text',
|
||||
text: 'MySite',
|
||||
imageSrc: '',
|
||||
imageWidth: '120px',
|
||||
href: '/',
|
||||
fontFamily: 'Inter, sans-serif',
|
||||
fontSize: '20px',
|
||||
fontWeight: '700',
|
||||
color: undefined,
|
||||
style: {},
|
||||
} as LogoProps,
|
||||
rules: {
|
||||
canDrag: () => true,
|
||||
canMoveIn: () => false,
|
||||
canMoveOut: () => true,
|
||||
},
|
||||
};
|
||||
|
||||
/* ---------- HTML export ---------- */
|
||||
|
||||
(Logo as any).toHtml = (props: LogoProps, _childrenHtml: string) => {
|
||||
const href = props.href || '/';
|
||||
|
||||
let innerHtml: string;
|
||||
if (props.type === 'image' && props.imageSrc) {
|
||||
const imgStyle = cssPropsToString({ width: props.imageWidth || '120px', height: 'auto', display: 'block' });
|
||||
innerHtml = `<img src="${escapeAttr(safeImageUrl(props.imageSrc))}" alt="${escapeAttr(props.text || 'Logo')}"${imgStyle ? ` style="${imgStyle}"` : ''} />`;
|
||||
} else {
|
||||
const spanStyle = cssPropsToString({
|
||||
fontWeight: props.fontWeight || '700',
|
||||
fontSize: props.fontSize || '20px',
|
||||
fontFamily: props.fontFamily || 'Inter, sans-serif',
|
||||
color: props.color || '#1f2937',
|
||||
});
|
||||
innerHtml = `<span${spanStyle ? ` style="${spanStyle}"` : ''}>${escapeHtml(props.text || 'MySite')}</span>`;
|
||||
}
|
||||
|
||||
const aStyle = cssPropsToString({
|
||||
textDecoration: 'none',
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
flexShrink: '0',
|
||||
...props.style,
|
||||
});
|
||||
|
||||
return {
|
||||
html: `<a href="${escapeAttr(safeUrl(href))}"${aStyle ? ` style="${aStyle}"` : ''}>${innerHtml}</a>`,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,46 @@
|
||||
import { describe, test, expect } from 'vitest';
|
||||
import { Menu } from './Menu';
|
||||
|
||||
const toHtml = (Menu as any).toHtml;
|
||||
|
||||
describe('Menu.toHtml deterministic + unique scope ids (thread node id, no Math.random)', () => {
|
||||
test('same node id -> identical output across calls (deterministic)', () => {
|
||||
const { html: html1 } = toHtml({}, '', 'node-menu1');
|
||||
const { html: html2 } = toHtml({}, '', 'node-menu1');
|
||||
expect(html1).toBe(html2);
|
||||
});
|
||||
|
||||
test('different node ids -> different, non-colliding scope classes (identical default links, no collision)', () => {
|
||||
const { html: html1 } = toHtml({}, '', 'node-menu1');
|
||||
const { html: html2 } = toHtml({}, '', 'node-menu2');
|
||||
const cls1 = html1.match(/\.([a-z0-9_]+-link):hover/)![1];
|
||||
const cls2 = html2.match(/\.([a-z0-9_]+-link):hover/)![1];
|
||||
expect(cls1).not.toBe(cls2);
|
||||
});
|
||||
|
||||
test('the anchor class= and the <style> hover rule use the SAME scope', () => {
|
||||
const { html } = toHtml({}, '', 'node-menu1');
|
||||
const hoverCls = html.match(/\.([a-z0-9_]+-link):hover/)![1];
|
||||
expect(html).toContain(`class="${hoverCls}"`);
|
||||
});
|
||||
|
||||
test('no nodeId (legacy 2-arg call): still deterministic across repeated calls, not random', () => {
|
||||
const { html: html1 } = toHtml({}, '');
|
||||
const { html: html2 } = toHtml({}, '');
|
||||
expect(html1).toBe(html2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Menu.toHtml XSS hardening (linkHoverColor into <style>)', () => {
|
||||
test('a linkHoverColor value containing </style><script> is neutralized', () => {
|
||||
const malicious = '#fff}</style><script>alert(1)</script><style>{';
|
||||
const { html } = toHtml({ linkHoverColor: malicious }, '', 'node-xss');
|
||||
expect(html).not.toContain('</style><script');
|
||||
expect(html).not.toContain('<script>alert(1)</script>');
|
||||
});
|
||||
|
||||
test('a normal linkHoverColor still renders in the hover rule', () => {
|
||||
const { html } = toHtml({ linkHoverColor: '#ff0000' }, '', 'node-normal');
|
||||
expect(html).toMatch(/:hover\s*\{\s*color:\s*#ff0000/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,190 @@
|
||||
import React, { CSSProperties, useState } from 'react';
|
||||
import { useNode, UserComponent } from '@craftjs/core';
|
||||
import { cssPropsToString } from '../../utils/style-helpers';
|
||||
import { escapeHtml, escapeAttr, safeUrl, scopeId, cssValue } from '../../utils/escape';
|
||||
|
||||
/* ---------- Types ---------- */
|
||||
|
||||
interface MenuLink {
|
||||
text: string;
|
||||
href: string;
|
||||
isExternal?: boolean;
|
||||
isCta?: boolean;
|
||||
}
|
||||
|
||||
interface MenuProps {
|
||||
links?: MenuLink[];
|
||||
alignment?: 'left' | 'center' | 'right';
|
||||
linkColor?: string;
|
||||
linkHoverColor?: string;
|
||||
ctaBgColor?: string;
|
||||
ctaTextColor?: string;
|
||||
gap?: string;
|
||||
orientation?: 'horizontal' | 'vertical';
|
||||
fontSize?: string;
|
||||
style?: CSSProperties;
|
||||
}
|
||||
|
||||
/* ---------- Defaults ---------- */
|
||||
|
||||
const defaultLinks: MenuLink[] = [
|
||||
{ text: 'Home', href: '/' },
|
||||
{ text: 'About', href: '#about' },
|
||||
{ text: 'Services', href: '#services' },
|
||||
{ text: 'Contact', href: '#contact', isCta: true },
|
||||
];
|
||||
|
||||
/* ---------- Component ---------- */
|
||||
|
||||
export const Menu: UserComponent<MenuProps> = ({
|
||||
links = defaultLinks,
|
||||
alignment = 'right',
|
||||
linkColor = '#3f3f46',
|
||||
linkHoverColor = '#3b82f6',
|
||||
ctaBgColor = '#3b82f6',
|
||||
ctaTextColor = '#ffffff',
|
||||
gap = '24px',
|
||||
orientation = 'horizontal',
|
||||
fontSize = '14px',
|
||||
style = {},
|
||||
}) => {
|
||||
const {
|
||||
connectors: { connect, drag },
|
||||
} = useNode();
|
||||
|
||||
const [hoveredLink, setHoveredLink] = useState<number | null>(null);
|
||||
|
||||
const justifyMap = { left: 'flex-start', center: 'center', right: 'flex-end' };
|
||||
|
||||
return (
|
||||
<nav
|
||||
ref={(ref: HTMLElement | null): void => { if (ref) connect(drag(ref)); }}
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: orientation === 'vertical' ? 'column' : 'row',
|
||||
alignItems: orientation === 'vertical' ? (alignment === 'center' ? 'center' : alignment === 'right' ? 'flex-end' : 'flex-start') : 'center',
|
||||
justifyContent: orientation === 'horizontal' ? justifyMap[alignment] : undefined,
|
||||
gap,
|
||||
flexWrap: 'wrap',
|
||||
...style,
|
||||
}}
|
||||
>
|
||||
{links.map((link, i) => (
|
||||
<a
|
||||
key={i}
|
||||
href={link.href}
|
||||
target={link.isExternal ? '_blank' : undefined}
|
||||
rel={link.isExternal ? 'noopener noreferrer' : undefined}
|
||||
onClick={(e) => e.preventDefault()}
|
||||
onMouseEnter={() => setHoveredLink(i)}
|
||||
onMouseLeave={() => setHoveredLink(null)}
|
||||
style={{
|
||||
textDecoration: 'none',
|
||||
fontSize,
|
||||
fontWeight: link.isCta ? '600' : '400',
|
||||
color: link.isCta
|
||||
? ctaTextColor
|
||||
: (hoveredLink === i ? linkHoverColor : linkColor),
|
||||
backgroundColor: link.isCta ? ctaBgColor : 'transparent',
|
||||
padding: link.isCta ? '8px 20px' : '0',
|
||||
borderRadius: link.isCta ? '6px' : '0',
|
||||
transition: 'color 0.15s, background-color 0.15s',
|
||||
...(link.isCta && hoveredLink === i ? { filter: 'brightness(1.1)' } : {}),
|
||||
}}
|
||||
>
|
||||
{link.text}
|
||||
</a>
|
||||
))}
|
||||
</nav>
|
||||
);
|
||||
};
|
||||
|
||||
/* ---------- Craft config ---------- */
|
||||
|
||||
Menu.craft = {
|
||||
displayName: 'Menu',
|
||||
props: {
|
||||
links: defaultLinks,
|
||||
alignment: 'right',
|
||||
linkColor: '#3f3f46',
|
||||
linkHoverColor: '#3b82f6',
|
||||
ctaBgColor: '#3b82f6',
|
||||
ctaTextColor: '#ffffff',
|
||||
gap: '24px',
|
||||
orientation: 'horizontal',
|
||||
fontSize: '14px',
|
||||
style: {},
|
||||
} as MenuProps,
|
||||
rules: {
|
||||
canDrag: () => true,
|
||||
canMoveIn: () => false,
|
||||
canMoveOut: () => true,
|
||||
},
|
||||
};
|
||||
|
||||
/* ---------- HTML export ---------- */
|
||||
|
||||
(Menu as any).toHtml = (props: MenuProps, _childrenHtml: string, nodeId?: string) => {
|
||||
// Sanitized once here -- linkCol/hoverCol/ctaBg/ctaText/gap/fSize are raw
|
||||
// string-interpolation sinks below (hoverCol goes into a <style> block,
|
||||
// the worst case: </style> breakout -> arbitrary <script>), see
|
||||
// task-cssxss-brief.md.
|
||||
const linkCol = cssValue(props.linkColor) || '#3f3f46';
|
||||
const hoverCol = cssValue(props.linkHoverColor) || '#3b82f6';
|
||||
const ctaBg = cssValue(props.ctaBgColor) || '#3b82f6';
|
||||
const ctaText = cssValue(props.ctaTextColor) || '#ffffff';
|
||||
const gap = cssValue(props.gap) || '24px';
|
||||
const orientation = props.orientation || 'horizontal';
|
||||
const alignment = props.alignment || 'right';
|
||||
const fSize = cssValue(props.fontSize) || '14px';
|
||||
|
||||
const justifyMap: Record<string, string> = { left: 'flex-start', center: 'center', right: 'flex-end' };
|
||||
|
||||
const navStyle = cssPropsToString({
|
||||
display: 'flex',
|
||||
flexDirection: orientation === 'vertical' ? 'column' : 'row',
|
||||
alignItems: orientation === 'vertical'
|
||||
? (alignment === 'center' ? 'center' : alignment === 'right' ? 'flex-end' : 'flex-start')
|
||||
: 'center',
|
||||
justifyContent: orientation === 'horizontal' ? justifyMap[alignment] : undefined,
|
||||
gap,
|
||||
flexWrap: 'wrap',
|
||||
...props.style,
|
||||
});
|
||||
|
||||
const links = props.links || defaultLinks;
|
||||
|
||||
// Scope for the hover CSS classes below. Deterministic AND unique: scoped
|
||||
// on the Craft node id so two Menu instances with identical/default links
|
||||
// don't collide on the same `.menu-link`/`.menu-cta` class names (which
|
||||
// would let one instance's hover styling bleed into the other's).
|
||||
const scope = scopeId(nodeId, JSON.stringify(links) + orientation + alignment, 'menu');
|
||||
|
||||
const linksHtml = links.map((link) => {
|
||||
const target = link.isExternal ? ' target="_blank" rel="noopener noreferrer"' : '';
|
||||
const cls = link.isCta ? `${scope}-cta` : `${scope}-link`;
|
||||
const linkStyle = cssPropsToString({
|
||||
textDecoration: 'none',
|
||||
fontSize: fSize,
|
||||
fontWeight: link.isCta ? '600' : '400',
|
||||
color: link.isCta ? ctaText : linkCol,
|
||||
backgroundColor: link.isCta ? ctaBg : 'transparent',
|
||||
padding: link.isCta ? '8px 20px' : '0',
|
||||
borderRadius: link.isCta ? '6px' : '0',
|
||||
transition: 'color 0.15s, background-color 0.15s',
|
||||
});
|
||||
return `<a href="${escapeAttr(safeUrl(link.href || '#'))}" class="${cls}"${target}${linkStyle ? ` style="${linkStyle}"` : ''}>${escapeHtml(link.text)}</a>`;
|
||||
}).join('\n ');
|
||||
|
||||
const hoverCss = `<style>
|
||||
.${scope}-link:hover { color: ${hoverCol} !important; }
|
||||
.${scope}-cta:hover { filter: brightness(1.1); }
|
||||
</style>`;
|
||||
|
||||
return {
|
||||
html: `${hoverCss}
|
||||
<nav${navStyle ? ` style="${navStyle}"` : ''}>
|
||||
${linksHtml}
|
||||
</nav>`,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,119 @@
|
||||
import { describe, test, expect } from 'vitest';
|
||||
import { Navbar } from './Navbar';
|
||||
|
||||
const toHtml = (Navbar as any).toHtml;
|
||||
|
||||
describe('Navbar.toHtml hamburger accessibility (F2.3)', () => {
|
||||
test('mobile toggle button has an accessible name, aria-expanded, and aria-controls', () => {
|
||||
const { html } = toHtml({ showMobileMenu: true }, '', 'node-nav1');
|
||||
expect(html).toMatch(/class="navbar-hamburger"[^>]*aria-label="Toggle navigation menu"/);
|
||||
expect(html).toMatch(/aria-expanded="false"/);
|
||||
expect(html).toMatch(/aria-controls="[^"]+"/);
|
||||
});
|
||||
|
||||
test('aria-controls target id exists on the links container', () => {
|
||||
const { html } = toHtml({ showMobileMenu: true }, '', 'node-nav1');
|
||||
const controls = html.match(/aria-controls="([^"]+)"/)![1];
|
||||
expect(html).toContain(`id="${controls}"`);
|
||||
});
|
||||
|
||||
test('toggle script flips aria-expanded on click', () => {
|
||||
const { html } = toHtml({ showMobileMenu: true }, '', 'node-nav1');
|
||||
expect(html).toMatch(/setAttribute\(['"]aria-expanded['"]/);
|
||||
});
|
||||
|
||||
test('no mobile menu: no hamburger button emitted', () => {
|
||||
const { html } = toHtml({ showMobileMenu: false }, '', 'node-nav1');
|
||||
expect(html).not.toContain('navbar-hamburger');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Navbar.toHtml node-scoped ids/hover styles (M-1: two navbars must not collide)', () => {
|
||||
test('no bare unscoped id="navbar-links" is emitted', () => {
|
||||
const { html } = toHtml({ showMobileMenu: true }, '', 'node-nav1');
|
||||
expect(html).not.toContain('id="navbar-links"');
|
||||
});
|
||||
|
||||
test('two different node ids produce different links-container ids', () => {
|
||||
const { html: html1 } = toHtml({ showMobileMenu: true }, '', 'node-nav1');
|
||||
const { html: html2 } = toHtml({ showMobileMenu: true }, '', 'node-nav2');
|
||||
const id1 = html1.match(/id="([^"]+)"/)![1];
|
||||
const id2 = html2.match(/id="([^"]+)"/)![1];
|
||||
expect(id1).not.toBe(id2);
|
||||
});
|
||||
|
||||
test('aria-controls always equals the actual links-container id', () => {
|
||||
const { html } = toHtml({ showMobileMenu: true }, '', 'node-nav1');
|
||||
const controls = html.match(/aria-controls="([^"]+)"/)![1];
|
||||
const linksId = html.match(/id="([^"]+)"/)![1];
|
||||
expect(controls).toBe(linksId);
|
||||
});
|
||||
|
||||
test('hover style selectors are scoped per-instance, not bare .navbar-link/.navbar-cta', () => {
|
||||
const { html } = toHtml({ hoverColor: '#ff0000' }, '', 'node-nav1');
|
||||
// A selector rule that STARTS the line with .navbar-link:hover (i.e. not
|
||||
// preceded by a per-instance ancestor class) would be the old, unscoped,
|
||||
// globally-colliding form.
|
||||
expect(html).not.toMatch(/^\s*\.navbar-link:hover/m);
|
||||
expect(html).not.toMatch(/^\s*\.navbar-cta:hover/m);
|
||||
// still present, just scoped under a per-instance ancestor class
|
||||
expect(html).toMatch(/\.navbar-link:hover/);
|
||||
expect(html).toMatch(/\.[\w-]+ \.navbar-link:hover/);
|
||||
});
|
||||
|
||||
test('two navbars with different hoverColor do not leak style onto each other (scoped selectors differ)', () => {
|
||||
const { html: html1 } = toHtml({ hoverColor: '#ff0000' }, '', 'node-nav1');
|
||||
const { html: html2 } = toHtml({ hoverColor: '#00ff00' }, '', 'node-nav2');
|
||||
const scope1 = html1.match(/<style>\s*\.([\w-]+)\s/)![1];
|
||||
const scope2 = html2.match(/<style>\s*\.([\w-]+)\s/)![1];
|
||||
expect(scope1).not.toBe(scope2);
|
||||
expect(html1).toContain(`.${scope1} .navbar-link:hover`);
|
||||
expect(html2).toContain(`.${scope2} .navbar-link:hover`);
|
||||
});
|
||||
|
||||
test('a normal single navbar still renders its hover style (visual output preserved)', () => {
|
||||
const { html } = toHtml({ hoverColor: '#ff0000' }, '', 'node-nav1');
|
||||
expect(html).toMatch(/:hover\s*\{\s*color:\s*#ff0000/);
|
||||
});
|
||||
|
||||
test('same node id -> identical output across calls (deterministic)', () => {
|
||||
const { html: html1 } = toHtml({ showMobileMenu: true }, '', 'node-nav1');
|
||||
const { html: html2 } = toHtml({ showMobileMenu: true }, '', 'node-nav1');
|
||||
expect(html1).toBe(html2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Navbar.toHtml XSS hardening (hoverColor/backgroundColor/ctaColor into <style>)', () => {
|
||||
test('a hoverColor value containing </style><script> is neutralized in the hover <style> block', () => {
|
||||
const malicious = '#fff}</style><script>alert(1)</script><style>{';
|
||||
const { html } = toHtml({ hoverColor: malicious }, '');
|
||||
expect(html).not.toContain('</style><script');
|
||||
expect(html).not.toContain('<script>alert(1)</script>');
|
||||
});
|
||||
|
||||
test('a backgroundColor value containing </style><script> is neutralized (mobile media-query rule)', () => {
|
||||
const malicious = '#fff}</style><script>alert(2)</script><style>{';
|
||||
const { html } = toHtml({ backgroundColor: malicious, showMobileMenu: true }, '');
|
||||
expect(html).not.toContain('</style><script');
|
||||
expect(html).not.toContain('<script>alert(2)</script>');
|
||||
});
|
||||
|
||||
test('a ctaColor value containing </style><script> is neutralized', () => {
|
||||
const malicious = '#fff}</style><script>alert(3)</script><style>{';
|
||||
const { html } = toHtml({ ctaColor: malicious }, '');
|
||||
expect(html).not.toContain('</style><script');
|
||||
expect(html).not.toContain('<script>alert(3)</script>');
|
||||
});
|
||||
|
||||
test('a textColor value containing a quote breakout does not escape the hamburger span style attribute', () => {
|
||||
const malicious = '#333" onmouseover="alert(1)';
|
||||
const { html } = toHtml({ textColor: malicious, showMobileMenu: true }, '');
|
||||
expect(html).not.toMatch(/style="[^"]*"[^>]*onmouseover/);
|
||||
});
|
||||
|
||||
test('normal colors still render correctly', () => {
|
||||
const { html } = toHtml({ hoverColor: '#ff0000', backgroundColor: '#123456', ctaColor: '#00ff00' }, '');
|
||||
expect(html).toMatch(/:hover\s*\{\s*color:\s*#ff0000/);
|
||||
expect(html).toContain('background-color:#123456');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,334 @@
|
||||
import React, { CSSProperties, useState } from 'react';
|
||||
import { useNode, UserComponent } from '@craftjs/core';
|
||||
import { cssPropsToString } from '../../utils/style-helpers';
|
||||
import { useSiteDesign } from '../../state/SiteDesignContext';
|
||||
import { escapeHtml, escapeAttr, safeUrl, safeImageUrl, cssValue, scopeId } from '../../utils/escape';
|
||||
|
||||
/* ---------- Types ---------- */
|
||||
|
||||
interface NavLink {
|
||||
text: string;
|
||||
href: string;
|
||||
isExternal?: boolean;
|
||||
isCta?: boolean;
|
||||
}
|
||||
|
||||
interface NavbarProps {
|
||||
logoType?: 'text' | 'image';
|
||||
logoText?: string;
|
||||
logoImage?: string;
|
||||
logoWidth?: string;
|
||||
logoUrl?: string;
|
||||
logoFontFamily?: string;
|
||||
logoFontSize?: string;
|
||||
logoColor?: string;
|
||||
links?: NavLink[];
|
||||
backgroundColor?: string;
|
||||
textColor?: string;
|
||||
hoverColor?: string;
|
||||
ctaColor?: string;
|
||||
ctaTextColor?: string;
|
||||
padding?: string;
|
||||
navAlignment?: 'left' | 'center' | 'right' | 'space-between';
|
||||
isSticky?: boolean;
|
||||
showMobileMenu?: boolean;
|
||||
style?: CSSProperties;
|
||||
}
|
||||
|
||||
/* ---------- Defaults ---------- */
|
||||
|
||||
const defaultLinks: NavLink[] = [
|
||||
{ text: 'Home', href: '/' },
|
||||
{ text: 'About', href: '#about' },
|
||||
{ text: 'Services', href: '#services' },
|
||||
{ text: 'Contact', href: '#contact', isCta: true },
|
||||
];
|
||||
|
||||
const PADDING_PRESETS = [
|
||||
{ label: 'Compact', value: '8px 16px' },
|
||||
{ label: 'Normal', value: '16px 24px' },
|
||||
{ label: 'Relaxed', value: '20px 32px' },
|
||||
{ label: 'Spacious', value: '24px 48px' },
|
||||
];
|
||||
|
||||
/* ---------- Component ---------- */
|
||||
|
||||
export const Navbar: UserComponent<NavbarProps> = ({
|
||||
logoType = 'text',
|
||||
logoText = 'MySite',
|
||||
logoImage = '',
|
||||
logoWidth = '120px',
|
||||
logoUrl = '/',
|
||||
logoFontFamily = 'Inter, sans-serif',
|
||||
logoFontSize = '20px',
|
||||
logoColor,
|
||||
links = defaultLinks,
|
||||
backgroundColor = '#ffffff',
|
||||
textColor = '#3f3f46',
|
||||
hoverColor = '#3b82f6',
|
||||
ctaColor = '#3b82f6',
|
||||
ctaTextColor = '#ffffff',
|
||||
padding = '16px 24px',
|
||||
navAlignment = 'space-between',
|
||||
isSticky = false,
|
||||
showMobileMenu = false,
|
||||
style = {},
|
||||
}) => {
|
||||
const {
|
||||
connectors: { connect, drag },
|
||||
selected,
|
||||
} = useNode((node) => ({
|
||||
selected: node.events.selected,
|
||||
}));
|
||||
|
||||
const { design } = useSiteDesign();
|
||||
const resolvedLogoColor = logoColor || (backgroundColor === '#ffffff' || backgroundColor === '#f8fafc' || backgroundColor === '#f9fafb' ? design.textColor : '#ffffff');
|
||||
const resolvedTextColor = textColor || (backgroundColor === '#ffffff' || backgroundColor === '#f8fafc' || backgroundColor === '#f9fafb' ? '#3f3f46' : '#e4e4e7');
|
||||
|
||||
const [hoveredLink, setHoveredLink] = useState<number | null>(null);
|
||||
|
||||
return (
|
||||
<nav
|
||||
ref={(ref: HTMLElement | null): void => { if (ref) connect(drag(ref)); }}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: navAlignment,
|
||||
padding,
|
||||
backgroundColor,
|
||||
...(isSticky ? { position: 'sticky' as const, top: 0, zIndex: 1000 } : {}),
|
||||
outline: selected ? '2px solid #3b82f6' : 'none',
|
||||
...style,
|
||||
}}
|
||||
>
|
||||
{/* Logo */}
|
||||
<a
|
||||
href={logoUrl}
|
||||
onClick={(e) => e.preventDefault()}
|
||||
style={{ textDecoration: 'none', display: 'flex', alignItems: 'center', flexShrink: 0 }}
|
||||
>
|
||||
{logoType === 'image' && logoImage ? (
|
||||
<img
|
||||
src={logoImage}
|
||||
alt={logoText || 'Logo'}
|
||||
style={{ width: logoWidth, height: 'auto', display: 'block' }}
|
||||
/>
|
||||
) : (
|
||||
<span style={{
|
||||
fontWeight: '700',
|
||||
fontSize: logoFontSize,
|
||||
fontFamily: logoFontFamily,
|
||||
color: resolvedLogoColor,
|
||||
}}>
|
||||
{logoText}
|
||||
</span>
|
||||
)}
|
||||
</a>
|
||||
|
||||
{/* Links */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '24px' }}>
|
||||
{showMobileMenu && (
|
||||
<div
|
||||
style={{
|
||||
display: 'none', /* Hidden in editor, shown via media query in export */
|
||||
flexDirection: 'column',
|
||||
gap: '4px',
|
||||
cursor: 'pointer',
|
||||
padding: '4px',
|
||||
}}
|
||||
className="navbar-hamburger"
|
||||
>
|
||||
<span style={{ display: 'block', width: '24px', height: '2px', backgroundColor: resolvedTextColor }} />
|
||||
<span style={{ display: 'block', width: '24px', height: '2px', backgroundColor: resolvedTextColor }} />
|
||||
<span style={{ display: 'block', width: '24px', height: '2px', backgroundColor: resolvedTextColor }} />
|
||||
</div>
|
||||
)}
|
||||
{links.map((link, i) => (
|
||||
<a
|
||||
key={i}
|
||||
href={link.href}
|
||||
target={link.isExternal ? '_blank' : undefined}
|
||||
rel={link.isExternal ? 'noopener noreferrer' : undefined}
|
||||
onClick={(e) => e.preventDefault()}
|
||||
onMouseEnter={() => setHoveredLink(i)}
|
||||
onMouseLeave={() => setHoveredLink(null)}
|
||||
style={{
|
||||
textDecoration: 'none',
|
||||
fontSize: '14px',
|
||||
fontWeight: link.isCta ? '600' : '400',
|
||||
color: link.isCta
|
||||
? ctaTextColor
|
||||
: (hoveredLink === i ? hoverColor : resolvedTextColor),
|
||||
backgroundColor: link.isCta ? ctaColor : 'transparent',
|
||||
padding: link.isCta ? '8px 20px' : '0',
|
||||
borderRadius: link.isCta ? '6px' : '0',
|
||||
transition: 'color 0.15s, background-color 0.15s',
|
||||
...(link.isCta && hoveredLink === i ? { filter: 'brightness(1.1)' } : {}),
|
||||
}}
|
||||
>
|
||||
{link.text}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
};
|
||||
|
||||
/* ---------- Craft config ---------- */
|
||||
|
||||
Navbar.craft = {
|
||||
displayName: 'Navbar',
|
||||
props: {
|
||||
logoType: 'text',
|
||||
logoText: 'MySite',
|
||||
logoImage: '',
|
||||
logoWidth: '120px',
|
||||
logoUrl: '/',
|
||||
logoFontFamily: 'Inter, sans-serif',
|
||||
logoFontSize: '20px',
|
||||
logoColor: undefined,
|
||||
links: defaultLinks,
|
||||
backgroundColor: '#ffffff',
|
||||
textColor: '#3f3f46',
|
||||
hoverColor: '#3b82f6',
|
||||
ctaColor: '#3b82f6',
|
||||
ctaTextColor: '#ffffff',
|
||||
padding: '16px 24px',
|
||||
navAlignment: 'space-between',
|
||||
isSticky: false,
|
||||
showMobileMenu: false,
|
||||
style: {
|
||||
borderBottom: '1px solid #e4e4e7',
|
||||
},
|
||||
} as NavbarProps,
|
||||
rules: {
|
||||
canDrag: () => true,
|
||||
canMoveIn: () => false,
|
||||
canMoveOut: () => true,
|
||||
},
|
||||
};
|
||||
|
||||
/* ---------- HTML export ---------- */
|
||||
|
||||
(Navbar as any).toHtml = (props: NavbarProps, _childrenHtml: string, nodeId?: string) => {
|
||||
// Sanitized once here -- these are raw string-interpolation sinks below
|
||||
// (hoverCol/bgColor go into a <style> block, the worst case: </style>
|
||||
// breakout -> arbitrary <script>), see task-cssxss-brief.md.
|
||||
const bgColor = cssValue(props.backgroundColor) || '#ffffff';
|
||||
const textCol = cssValue(props.textColor) || '#3f3f46';
|
||||
const hoverCol = cssValue(props.hoverColor) || '#3b82f6';
|
||||
const ctaCol = cssValue(props.ctaColor) || '#3b82f6';
|
||||
const ctaTextCol = cssValue(props.ctaTextColor) || '#ffffff';
|
||||
const pad = cssValue(props.padding) || '16px 24px';
|
||||
const alignment = props.navAlignment || 'space-between';
|
||||
const sticky = props.isSticky;
|
||||
const mobile = props.showMobileMenu;
|
||||
const logoUrl = props.logoUrl || '/';
|
||||
const links0 = props.links || defaultLinks;
|
||||
|
||||
// M-1: deterministic AND unique per-instance scope, keyed on the Craft
|
||||
// node id. Two Navbars on the same page previously emitted an identical
|
||||
// fixed id="navbar-links" (invalid duplicate-id HTML, ambiguous
|
||||
// aria-controls target) and unscoped `.navbar-link:hover`/`.navbar-cta:hover`
|
||||
// rules in each instance's own <style> block -- since both blocks target
|
||||
// the SAME global selector, the later one in the DOM silently overrides
|
||||
// the earlier one's hover color/behavior for BOTH navbars. Scoping the
|
||||
// links-container id and adding a per-instance class on the <nav> root
|
||||
// (used to prefix the hover selectors) eliminates both collisions.
|
||||
const scope = scopeId(nodeId, JSON.stringify(links0) + alignment + pad, 'nav');
|
||||
const linksId = `${scope}_links`;
|
||||
|
||||
const navStyle = cssPropsToString({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: alignment,
|
||||
padding: pad,
|
||||
backgroundColor: bgColor,
|
||||
...(sticky ? { position: 'sticky', top: '0', zIndex: '1000' } : {}),
|
||||
...props.style,
|
||||
});
|
||||
|
||||
// Logo HTML
|
||||
let logoHtml: string;
|
||||
if (props.logoType === 'image' && props.logoImage) {
|
||||
const imgStyle = cssPropsToString({ width: props.logoWidth || '120px', height: 'auto', display: 'block' });
|
||||
logoHtml = `<a href="${escapeAttr(safeUrl(logoUrl))}" style="text-decoration:none;display:flex;align-items:center;flex-shrink:0"><img src="${escapeAttr(safeImageUrl(props.logoImage))}" alt="${escapeAttr(props.logoText || 'Logo')}"${imgStyle ? ` style="${imgStyle}"` : ''} /></a>`;
|
||||
} else {
|
||||
const logoStyle = cssPropsToString({
|
||||
fontWeight: '700',
|
||||
fontSize: props.logoFontSize || '20px',
|
||||
fontFamily: props.logoFontFamily || 'Inter, sans-serif',
|
||||
color: props.logoColor || textCol,
|
||||
});
|
||||
logoHtml = `<a href="${escapeAttr(safeUrl(logoUrl))}" style="text-decoration:none;display:flex;align-items:center;flex-shrink:0"><span${logoStyle ? ` style="${logoStyle}"` : ''}>${escapeHtml(props.logoText || 'MySite')}</span></a>`;
|
||||
}
|
||||
|
||||
// Links HTML
|
||||
const links = props.links || defaultLinks;
|
||||
const linksHtml = links.map((link) => {
|
||||
const target = link.isExternal ? ' target="_blank" rel="noopener noreferrer"' : '';
|
||||
const linkStyle = cssPropsToString({
|
||||
textDecoration: 'none',
|
||||
fontSize: '14px',
|
||||
fontWeight: link.isCta ? '600' : '400',
|
||||
color: link.isCta ? ctaTextCol : textCol,
|
||||
backgroundColor: link.isCta ? ctaCol : 'transparent',
|
||||
padding: link.isCta ? '8px 20px' : '0',
|
||||
borderRadius: link.isCta ? '6px' : '0',
|
||||
transition: 'color 0.15s, background-color 0.15s',
|
||||
});
|
||||
return `<a href="${escapeAttr(safeUrl(link.href || "#"))}"${target}${linkStyle ? ` style="${linkStyle}"` : ''}>${escapeHtml(link.text)}</a>`;
|
||||
}).join('\n ');
|
||||
|
||||
// Hamburger HTML for mobile. The toggle needs an accessible name (there's
|
||||
// no visible text, just three bars) and must report its open/closed state
|
||||
// via aria-expanded, kept in sync with the .navbar-open class by the
|
||||
// inline onclick handler.
|
||||
const hamburgerHtml = mobile
|
||||
? `\n <button class="navbar-hamburger" aria-label="Toggle navigation menu" aria-expanded="false" aria-controls="${escapeAttr(linksId)}" onclick="var m=document.getElementById('${linksId}');var open=m.classList.toggle('navbar-open');this.setAttribute('aria-expanded', open ? 'true' : 'false');" style="display:none;background:none;border:none;cursor:pointer;padding:4px;flex-direction:column;gap:4px">
|
||||
<span style="display:block;width:24px;height:2px;background-color:${escapeAttr(textCol)}"></span>
|
||||
<span style="display:block;width:24px;height:2px;background-color:${escapeAttr(textCol)}"></span>
|
||||
<span style="display:block;width:24px;height:2px;background-color:${escapeAttr(textCol)}"></span>
|
||||
</button>`
|
||||
: '';
|
||||
|
||||
// Hover CSS -- scoped under `.${scope}` (a class on the <nav> root, added
|
||||
// below) so it can only ever match THIS instance's links/CTA, never bleed
|
||||
// into or get overridden by another Navbar instance's rules.
|
||||
const hoverCss = `<style>
|
||||
.${scope} .navbar-link:hover { color: ${hoverCol} !important; }
|
||||
.${scope} .navbar-cta:hover { filter: brightness(1.1); }${mobile ? `
|
||||
@media (max-width: 768px) {
|
||||
.${scope} .navbar-hamburger { display: flex !important; }
|
||||
.${scope} .navbar-links { display: none !important; position: absolute; top: 100%; left: 0; right: 0; flex-direction: column !important; background-color: ${bgColor}; padding: 12px 24px; gap: 12px !important; box-shadow: 0 4px 12px rgba(0,0,0,0.1); }
|
||||
.${scope} .navbar-links.navbar-open { display: flex !important; }
|
||||
}` : ''}
|
||||
</style>`;
|
||||
|
||||
// Add CSS class to each link for hover
|
||||
const linksHtmlWithClass = links.map((link) => {
|
||||
const target = link.isExternal ? ' target="_blank" rel="noopener noreferrer"' : '';
|
||||
const cls = link.isCta ? 'navbar-cta' : 'navbar-link';
|
||||
const linkStyle = cssPropsToString({
|
||||
textDecoration: 'none',
|
||||
fontSize: '14px',
|
||||
fontWeight: link.isCta ? '600' : '400',
|
||||
color: link.isCta ? ctaTextCol : textCol,
|
||||
backgroundColor: link.isCta ? ctaCol : 'transparent',
|
||||
padding: link.isCta ? '8px 20px' : '0',
|
||||
borderRadius: link.isCta ? '6px' : '0',
|
||||
transition: 'color 0.15s, background-color 0.15s',
|
||||
});
|
||||
return `<a href="${escapeAttr(safeUrl(link.href || "#"))}" class="${cls}"${target}${linkStyle ? ` style="${linkStyle}"` : ''}>${escapeHtml(link.text)}</a>`;
|
||||
}).join('\n ');
|
||||
|
||||
return {
|
||||
html: `${hoverCss}
|
||||
<nav class="${scope}"${navStyle ? ` style="${navStyle}${mobile ? ';position:relative' : ''}"` : ''}>
|
||||
${logoHtml}${hamburgerHtml}
|
||||
<div class="navbar-links" id="${linksId}" style="display:flex;align-items:center;gap:24px">
|
||||
${linksHtmlWithClass}
|
||||
</div>
|
||||
</nav>`,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,32 @@
|
||||
import { describe, test, expect } from 'vitest';
|
||||
import { SearchBar } from './SearchBar';
|
||||
|
||||
const toHtml = (SearchBar as any).toHtml;
|
||||
|
||||
describe('SearchBar.toHtml decorative icons (F2.5)', () => {
|
||||
test('the input-adjacent search icon is aria-hidden', () => {
|
||||
const { html } = toHtml({}, '');
|
||||
const icons = html.match(/<i class="fa fa-search"[^>]*>/g) || [];
|
||||
expect(icons.length).toBeGreaterThan(0);
|
||||
icons.forEach((tag: string) => expect(tag).toContain('aria-hidden="true"'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('SearchBar.toHtml XSS hardening (placeholder/buttonText/showButton)', () => {
|
||||
test('a placeholder value with an attribute-breakout string cannot escape placeholder=""', () => {
|
||||
const malicious = 'Search..." onmouseover="alert(1)';
|
||||
const { html } = toHtml({ placeholder: malicious }, '');
|
||||
expect(html).not.toMatch(/"\s+onmouseover="/);
|
||||
});
|
||||
|
||||
test('a buttonText value with a script tag is escaped as text content, not raw HTML', () => {
|
||||
const malicious = '<script>alert(1)</script>';
|
||||
const { html } = toHtml({ buttonText: malicious, showButton: true }, '');
|
||||
expect(html).not.toContain('<script>alert(1)</script>');
|
||||
});
|
||||
|
||||
test('a non-boolean showButton (string "false") still yields fixed, safe border-radius values', () => {
|
||||
const { html } = toHtml({ showButton: 'false' as any }, '');
|
||||
expect(html).toMatch(/border-radius:(8px 0 0 8px|8px)/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,145 @@
|
||||
import React, { CSSProperties } from 'react';
|
||||
import { useNode, UserComponent } from '@craftjs/core';
|
||||
import { cssPropsToString } from '../../utils/style-helpers';
|
||||
import { escapeHtml, escapeAttr } from '../../utils/escape';
|
||||
|
||||
interface SearchBarProps {
|
||||
placeholder?: string;
|
||||
buttonText?: string;
|
||||
showButton?: boolean;
|
||||
style?: CSSProperties;
|
||||
}
|
||||
|
||||
export const SearchBar: UserComponent<SearchBarProps> = ({
|
||||
placeholder = 'Search...',
|
||||
buttonText = 'Search',
|
||||
showButton = true,
|
||||
style = {},
|
||||
}) => {
|
||||
const {
|
||||
connectors: { connect, drag },
|
||||
selected,
|
||||
} = useNode((node) => ({
|
||||
selected: node.events.selected,
|
||||
}));
|
||||
|
||||
return (
|
||||
<form
|
||||
ref={(ref: HTMLFormElement | null): void => { if (ref) connect(drag(ref)); }}
|
||||
role="search"
|
||||
onSubmit={(e) => e.preventDefault()}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
maxWidth: '560px',
|
||||
outline: selected ? '2px solid #3b82f6' : 'none',
|
||||
...style,
|
||||
}}
|
||||
>
|
||||
<div style={{ position: 'relative', flex: 1 }}>
|
||||
<i
|
||||
className="fa fa-search"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: '14px',
|
||||
top: '50%',
|
||||
transform: 'translateY(-50%)',
|
||||
color: '#9ca3af',
|
||||
fontSize: '14px',
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
/>
|
||||
<input
|
||||
type="search"
|
||||
placeholder={placeholder}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '12px 16px 12px 40px',
|
||||
fontSize: '15px',
|
||||
fontFamily: 'Inter, sans-serif',
|
||||
border: '1px solid #d1d5db',
|
||||
borderRadius: showButton ? '8px 0 0 8px' : '8px',
|
||||
backgroundColor: '#ffffff',
|
||||
color: '#1f2937',
|
||||
outline: 'none',
|
||||
boxSizing: 'border-box',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{showButton && (
|
||||
<button
|
||||
type="submit"
|
||||
style={{
|
||||
padding: '12px 20px',
|
||||
fontSize: '15px',
|
||||
fontWeight: '600',
|
||||
fontFamily: 'Inter, sans-serif',
|
||||
color: '#ffffff',
|
||||
backgroundColor: '#3b82f6',
|
||||
border: 'none',
|
||||
borderRadius: '0 8px 8px 0',
|
||||
cursor: 'pointer',
|
||||
whiteSpace: 'nowrap',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '6px',
|
||||
}}
|
||||
>
|
||||
<i className="fa fa-search" style={{ fontSize: '13px' }} />
|
||||
{buttonText}
|
||||
</button>
|
||||
)}
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
/* ---------- Craft config ---------- */
|
||||
|
||||
SearchBar.craft = {
|
||||
displayName: 'Search Bar',
|
||||
props: {
|
||||
placeholder: 'Search...',
|
||||
buttonText: 'Search',
|
||||
showButton: true,
|
||||
style: {},
|
||||
},
|
||||
rules: {
|
||||
canDrag: () => true,
|
||||
canMoveIn: () => false,
|
||||
canMoveOut: () => true,
|
||||
},
|
||||
};
|
||||
|
||||
/* ---------- HTML export ---------- */
|
||||
|
||||
(SearchBar as any).toHtml = (props: SearchBarProps, _childrenHtml: string) => {
|
||||
const {
|
||||
placeholder = 'Search...',
|
||||
buttonText = 'Search',
|
||||
showButton = true,
|
||||
style = {},
|
||||
} = props;
|
||||
|
||||
const formStyle = cssPropsToString({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
maxWidth: '560px',
|
||||
...style,
|
||||
});
|
||||
|
||||
const inputStyleStr = `width:100%;padding:12px 16px 12px 40px;font-size:15px;font-family:Inter,sans-serif;border:1px solid #d1d5db;border-radius:${showButton ? '8px 0 0 8px' : '8px'};background-color:#ffffff;color:#1f2937;outline:none;box-sizing:border-box`;
|
||||
|
||||
const btnHtml = showButton
|
||||
? `<button type="submit" style="padding:12px 20px;font-size:15px;font-weight:600;font-family:Inter,sans-serif;color:#ffffff;background-color:#3b82f6;border:none;border-radius:0 8px 8px 0;cursor:pointer;white-space:nowrap;display:flex;align-items:center;gap:6px"><i class="fa fa-search" style="font-size:13px" aria-hidden="true"></i>${escapeHtml(buttonText)}</button>`
|
||||
: '';
|
||||
|
||||
return {
|
||||
html: `<form role="search"${formStyle ? ` style="${formStyle}"` : ''}>
|
||||
<div style="position:relative;flex:1">
|
||||
<i class="fa fa-search" style="position:absolute;left:14px;top:50%;transform:translateY(-50%);color:#9ca3af;font-size:14px;pointer-events:none" aria-hidden="true"></i>
|
||||
<input type="search" placeholder="${escapeAttr(placeholder)}" style="${inputStyleStr}" />
|
||||
</div>
|
||||
${btnHtml}
|
||||
</form>`,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,93 @@
|
||||
import { describe, test, expect } from 'vitest';
|
||||
import { SocialLinks } from './SocialLinks';
|
||||
|
||||
const toHtml = (SocialLinks as any).toHtml;
|
||||
|
||||
describe('SocialLinks.toHtml accessibility (F2.5)', () => {
|
||||
test('icon-only links get an aria-label naming the platform', () => {
|
||||
const { html } = toHtml({ links: [{ platform: 'facebook', url: 'https://fb.example/x' }] }, '');
|
||||
expect(html).toMatch(/<a[^>]*aria-label="Facebook"/);
|
||||
});
|
||||
|
||||
test('the icon glyph itself is aria-hidden', () => {
|
||||
const { html } = toHtml({ links: [{ platform: 'twitter', url: '#' }] }, '');
|
||||
expect(html).toMatch(/<i class="fa fa-twitter"[^>]*aria-hidden="true"/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('SocialLinks.toHtml XSS hardening (iconSize/iconColor/iconBgColor/gap into style=)', () => {
|
||||
test('an iconSize value with an attribute-breakout string cannot escape style=""', () => {
|
||||
const malicious = '20px" onmouseover="alert(1)';
|
||||
const { html } = toHtml({ links: [{ platform: 'facebook', url: '#' }], iconSize: malicious as any }, '');
|
||||
expect(html).not.toMatch(/"\s+onmouseover="/);
|
||||
});
|
||||
|
||||
test('an iconColor value with an attribute-breakout string cannot escape style=""', () => {
|
||||
const malicious = '#fff" onmouseover="alert(1)';
|
||||
const { html } = toHtml({ links: [{ platform: 'facebook', url: '#' }], iconColor: malicious as any }, '');
|
||||
expect(html).not.toMatch(/"\s+onmouseover="/);
|
||||
});
|
||||
|
||||
test('an iconBgColor value with an attribute-breakout string cannot escape style=""', () => {
|
||||
const malicious = '#374151" onmouseover="alert(1)';
|
||||
const { html } = toHtml({ links: [{ platform: 'facebook', url: '#' }], iconShape: 'circle', iconBgColor: malicious as any }, '');
|
||||
expect(html).not.toMatch(/"\s+onmouseover="/);
|
||||
});
|
||||
|
||||
test('a gap value with an attribute-breakout string cannot escape the wrapper style=""', () => {
|
||||
const malicious = '10px" onmouseover="alert(1)';
|
||||
const { html } = toHtml({ links: [{ platform: 'facebook', url: '#' }], gap: malicious as any }, '');
|
||||
expect(html).not.toMatch(/"\s+onmouseover="/);
|
||||
});
|
||||
|
||||
test('a malicious platform key does not produce a raw class-attribute breakout', () => {
|
||||
const malicious = 'x"><script>alert(1)</script>';
|
||||
const { html } = toHtml({ links: [{ platform: malicious, url: '#' }] }, '');
|
||||
expect(html).not.toContain('<script>alert(1)</script>');
|
||||
});
|
||||
|
||||
test('a link url with a javascript: scheme is neutralized', () => {
|
||||
const { html } = toHtml({ links: [{ platform: 'facebook', url: 'javascript:alert(1)' }] }, '');
|
||||
expect(html).not.toContain('javascript:alert(1)');
|
||||
});
|
||||
});
|
||||
|
||||
describe('SocialLinks.toHtml iconShape/gap emission (previously built but unexposed in SocialStylePanel)', () => {
|
||||
test('iconShape="circle" emits a 50% border-radius on the wrapping <a>', () => {
|
||||
const { html } = toHtml({ links: [{ platform: 'facebook', url: '#' }], iconShape: 'circle' }, '');
|
||||
expect(html).toMatch(/<a[^>]*style="[^"]*border-radius:50%[^"]*"/);
|
||||
});
|
||||
|
||||
test('iconShape="square" emits border-radius:0', () => {
|
||||
const { html } = toHtml({ links: [{ platform: 'facebook', url: '#' }], iconShape: 'square' }, '');
|
||||
expect(html).toMatch(/<a[^>]*style="[^"]*border-radius:0[^"]*"/);
|
||||
});
|
||||
|
||||
test('iconShape="rounded" emits a 6px border-radius', () => {
|
||||
const { html } = toHtml({ links: [{ platform: 'facebook', url: '#' }], iconShape: 'rounded' }, '');
|
||||
expect(html).toMatch(/<a[^>]*style="[^"]*border-radius:6px[^"]*"/);
|
||||
});
|
||||
|
||||
test('iconShape="none" omits the background box entirely (transparent bg, no fixed box size)', () => {
|
||||
const { html } = toHtml({ links: [{ platform: 'facebook', url: '#' }], iconShape: 'none' }, '');
|
||||
const aTag = html.match(/<a[^>]*>/)![0];
|
||||
expect(aTag).toContain('background-color:transparent');
|
||||
expect(aTag).not.toContain('border-radius');
|
||||
});
|
||||
|
||||
test('gap emits on the wrapper <div> style', () => {
|
||||
const { html } = toHtml({ links: [{ platform: 'facebook', url: '#' }], gap: '24px' }, '');
|
||||
expect(html).toMatch(/<div[^>]*style="[^"]*gap:24px[^"]*"/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('SocialLinks.craft.props includes the box-model/animation/visibility rollout props', () => {
|
||||
test('animation, animationDelay, and all 3 hideOn* flags are declared (blank/false defaults)', () => {
|
||||
const props = (SocialLinks as any).craft.props;
|
||||
expect(props).toHaveProperty('animation', '');
|
||||
expect(props).toHaveProperty('animationDelay', '');
|
||||
expect(props).toHaveProperty('hideOnDesktop', false);
|
||||
expect(props).toHaveProperty('hideOnTablet', false);
|
||||
expect(props).toHaveProperty('hideOnMobile', false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,230 @@
|
||||
import React, { CSSProperties } from 'react';
|
||||
import { useNode, UserComponent } from '@craftjs/core';
|
||||
import { cssPropsToString } from '../../utils/style-helpers';
|
||||
import { escapeAttr, safeUrl, cssValue } from '../../utils/escape';
|
||||
|
||||
interface SocialLink {
|
||||
platform: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
interface SocialLinksProps {
|
||||
links?: SocialLink[];
|
||||
iconSize?: string;
|
||||
iconColor?: string;
|
||||
iconBgColor?: string;
|
||||
iconShape?: 'none' | 'circle' | 'square' | 'rounded';
|
||||
gap?: string;
|
||||
alignment?: 'left' | 'center' | 'right';
|
||||
style?: CSSProperties;
|
||||
animation?: string;
|
||||
animationDelay?: string;
|
||||
hideOnDesktop?: boolean;
|
||||
hideOnTablet?: boolean;
|
||||
hideOnMobile?: boolean;
|
||||
}
|
||||
|
||||
const platformIcons: Record<string, string> = {
|
||||
facebook: 'fa-facebook',
|
||||
twitter: 'fa-twitter',
|
||||
instagram: 'fa-instagram',
|
||||
linkedin: 'fa-linkedin',
|
||||
youtube: 'fa-youtube',
|
||||
github: 'fa-github',
|
||||
tiktok: 'fa-music',
|
||||
pinterest: 'fa-pinterest',
|
||||
snapchat: 'fa-snapchat',
|
||||
whatsapp: 'fa-whatsapp',
|
||||
spotify: 'fa-spotify',
|
||||
twitch: 'fa-twitch',
|
||||
};
|
||||
|
||||
const platformLabels: Record<string, string> = {
|
||||
facebook: 'Facebook',
|
||||
twitter: 'Twitter / X',
|
||||
instagram: 'Instagram',
|
||||
linkedin: 'LinkedIn',
|
||||
youtube: 'YouTube',
|
||||
github: 'GitHub',
|
||||
tiktok: 'TikTok',
|
||||
pinterest: 'Pinterest',
|
||||
snapchat: 'Snapchat',
|
||||
whatsapp: 'WhatsApp',
|
||||
spotify: 'Spotify',
|
||||
twitch: 'Twitch',
|
||||
};
|
||||
|
||||
const defaultLinks: SocialLink[] = [
|
||||
{ platform: 'facebook', url: '#' },
|
||||
{ platform: 'twitter', url: '#' },
|
||||
{ platform: 'instagram', url: '#' },
|
||||
{ platform: 'linkedin', url: '#' },
|
||||
];
|
||||
|
||||
const getShapeStyle = (shape: string, size: string): CSSProperties => {
|
||||
if (shape === 'none') return {};
|
||||
const numSize = parseInt(size) || 24;
|
||||
const boxSize = `${numSize + 16}px`;
|
||||
const base: CSSProperties = {
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
width: boxSize,
|
||||
height: boxSize,
|
||||
};
|
||||
if (shape === 'circle') return { ...base, borderRadius: '50%' };
|
||||
if (shape === 'square') return { ...base, borderRadius: '0' };
|
||||
if (shape === 'rounded') return { ...base, borderRadius: '6px' };
|
||||
return base;
|
||||
};
|
||||
|
||||
const alignMap: Record<string, string> = {
|
||||
left: 'flex-start',
|
||||
center: 'center',
|
||||
right: 'flex-end',
|
||||
};
|
||||
|
||||
export const SocialLinks: UserComponent<SocialLinksProps> = ({
|
||||
links = defaultLinks,
|
||||
iconSize = '20px',
|
||||
iconColor = '#ffffff',
|
||||
iconBgColor = '#374151',
|
||||
iconShape = 'circle',
|
||||
gap = '10px',
|
||||
alignment = 'center',
|
||||
style = {},
|
||||
}) => {
|
||||
const {
|
||||
connectors: { connect, drag },
|
||||
selected,
|
||||
} = useNode((node) => ({
|
||||
selected: node.events.selected,
|
||||
}));
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={(ref: HTMLDivElement | null): void => { if (ref) connect(drag(ref)); }}
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
gap,
|
||||
justifyContent: alignMap[alignment] || 'center',
|
||||
alignItems: 'center',
|
||||
outline: selected ? '2px solid #3b82f6' : 'none',
|
||||
...style,
|
||||
}}
|
||||
>
|
||||
{links.map((link, i) => {
|
||||
const iconClass = platformIcons[link.platform] || 'fa-link';
|
||||
const shapeStyle = getShapeStyle(iconShape, iconSize);
|
||||
const hasBg = iconShape !== 'none';
|
||||
|
||||
return (
|
||||
<a
|
||||
key={i}
|
||||
href={link.url || '#'}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={(e) => e.preventDefault()}
|
||||
title={platformLabels[link.platform] || link.platform}
|
||||
style={{
|
||||
textDecoration: 'none',
|
||||
color: iconColor,
|
||||
backgroundColor: hasBg ? iconBgColor : 'transparent',
|
||||
transition: 'opacity 0.2s',
|
||||
...shapeStyle,
|
||||
}}
|
||||
>
|
||||
<i className={`fa ${iconClass}`} style={{ fontSize: iconSize }} />
|
||||
</a>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/* ---------- Craft config ---------- */
|
||||
|
||||
SocialLinks.craft = {
|
||||
displayName: 'Social Links',
|
||||
props: {
|
||||
links: defaultLinks,
|
||||
iconSize: '20px',
|
||||
iconColor: '#ffffff',
|
||||
iconBgColor: '#374151',
|
||||
iconShape: 'circle',
|
||||
gap: '10px',
|
||||
alignment: 'center',
|
||||
style: {},
|
||||
animation: '',
|
||||
animationDelay: '',
|
||||
hideOnDesktop: false,
|
||||
hideOnTablet: false,
|
||||
hideOnMobile: false,
|
||||
},
|
||||
rules: {
|
||||
canDrag: () => true,
|
||||
canMoveIn: () => false,
|
||||
canMoveOut: () => true,
|
||||
},
|
||||
};
|
||||
|
||||
/* ---------- HTML export ---------- */
|
||||
|
||||
(SocialLinks as any).toHtml = (props: SocialLinksProps, _childrenHtml: string) => {
|
||||
const links = props.links || defaultLinks;
|
||||
// Sanitized -- raw string-interpolation sinks in aStyle/getShapeStr below.
|
||||
const iconSize = cssValue(props.iconSize) || '20px';
|
||||
const iconColor = cssValue(props.iconColor) || '#ffffff';
|
||||
const iconBgColor = cssValue(props.iconBgColor) || '#374151';
|
||||
const iconShape = props.iconShape || 'circle';
|
||||
const gap = props.gap || '10px';
|
||||
const alignment = props.alignment || 'center';
|
||||
const hasBg = iconShape !== 'none';
|
||||
|
||||
const wrapperStyle = cssPropsToString({
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
gap,
|
||||
justifyContent: alignMap[alignment] || 'center',
|
||||
alignItems: 'center',
|
||||
...props.style,
|
||||
});
|
||||
|
||||
const numSize = parseInt(iconSize) || 20;
|
||||
const boxSize = `${numSize + 16}px`;
|
||||
|
||||
const getShapeStr = (): string => {
|
||||
const parts: string[] = [
|
||||
`display:inline-flex`,
|
||||
`align-items:center`,
|
||||
`justify-content:center`,
|
||||
`width:${boxSize}`,
|
||||
`height:${boxSize}`,
|
||||
];
|
||||
if (iconShape === 'circle') parts.push('border-radius:50%');
|
||||
else if (iconShape === 'square') parts.push('border-radius:0');
|
||||
else if (iconShape === 'rounded') parts.push('border-radius:6px');
|
||||
return parts.join(';');
|
||||
};
|
||||
|
||||
const linksHtml = links.map((link) => {
|
||||
const iconClass = platformIcons[link.platform] || 'fa-link';
|
||||
const title = platformLabels[link.platform] || link.platform;
|
||||
let aStyle = `text-decoration:none;color:${iconColor};background-color:${hasBg ? iconBgColor : 'transparent'}`;
|
||||
if (hasBg) {
|
||||
aStyle += `;${getShapeStr()}`;
|
||||
}
|
||||
// The link's only content is the icon glyph, so the glyph itself is
|
||||
// aria-hidden and the accessible name lives on the link (aria-label,
|
||||
// mirroring the existing `title` tooltip since title support in
|
||||
// screen readers is inconsistent).
|
||||
return `<a href="${escapeAttr(safeUrl(link.url || '#'))}" target="_blank" rel="noopener noreferrer" title="${escapeAttr(title)}" aria-label="${escapeAttr(title)}" style="${aStyle}"><i class="fa ${escapeAttr(iconClass)}" style="font-size:${iconSize}" aria-hidden="true"></i></a>`;
|
||||
}).join('\n ');
|
||||
|
||||
return {
|
||||
html: `<div${wrapperStyle ? ` style="${wrapperStyle}"` : ''}>
|
||||
${linksHtml}
|
||||
</div>`,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,26 @@
|
||||
import { describe, test, expect } from 'vitest';
|
||||
import { Spacer } from './Spacer';
|
||||
|
||||
const toHtml = (Spacer as any).toHtml;
|
||||
|
||||
describe('Spacer.toHtml normal rendering', () => {
|
||||
test('renders height into the style attribute', () => {
|
||||
const { html } = toHtml({ height: '80px' }, '');
|
||||
expect(html).toContain('height:80px');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Spacer.toHtml XSS hardening (height into style=)', () => {
|
||||
test('a height value with an attribute-breakout string cannot escape style=""', () => {
|
||||
const malicious = '40px" onmouseover="alert(1)';
|
||||
const { html } = toHtml({ height: malicious as any }, '');
|
||||
expect(html).not.toMatch(/"\s+onmouseover="/);
|
||||
expect(html).not.toMatch(/style="[^"]*"[^>]*onmouseover/);
|
||||
});
|
||||
|
||||
test('a height value with a </style><script> breakout is neutralized', () => {
|
||||
const malicious = '40px</style><script>alert(1)</script>';
|
||||
const { html } = toHtml({ height: malicious as any }, '');
|
||||
expect(html).not.toContain('<script>alert(1)</script>');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
import React, { CSSProperties } from 'react';
|
||||
import { useNode, UserComponent } from '@craftjs/core';
|
||||
import { cssPropsToString } from '../../utils/style-helpers';
|
||||
|
||||
interface SpacerProps {
|
||||
height?: string;
|
||||
style?: CSSProperties;
|
||||
}
|
||||
|
||||
export const Spacer: UserComponent<SpacerProps> = ({
|
||||
height = '40px',
|
||||
style = {},
|
||||
}) => {
|
||||
const {
|
||||
connectors: { connect, drag },
|
||||
selected,
|
||||
} = useNode((node) => ({
|
||||
selected: node.events.selected,
|
||||
}));
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={(ref: HTMLElement | null): void => { if (ref) connect(drag(ref)); }}
|
||||
style={{
|
||||
height,
|
||||
outline: selected ? '2px dashed #3b82f6' : 'none',
|
||||
...style,
|
||||
...(selected && !style.backgroundColor && !style.background
|
||||
? { background: 'rgba(59,130,246,0.05)' }
|
||||
: {}),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
/* ---------- Craft config ---------- */
|
||||
|
||||
Spacer.craft = {
|
||||
displayName: 'Spacer',
|
||||
props: {
|
||||
height: '40px',
|
||||
style: {},
|
||||
},
|
||||
rules: {
|
||||
canDrag: () => true,
|
||||
canMoveIn: () => false,
|
||||
canMoveOut: () => true,
|
||||
},
|
||||
};
|
||||
|
||||
/* ---------- HTML export ---------- */
|
||||
|
||||
(Spacer as any).toHtml = (props: SpacerProps, _childrenHtml: string) => {
|
||||
const styleStr = cssPropsToString({
|
||||
height: props.height || '40px',
|
||||
...props.style,
|
||||
});
|
||||
return { html: `<div${styleStr ? ` style="${styleStr}"` : ''}></div>` };
|
||||
};
|
||||
@@ -0,0 +1,88 @@
|
||||
import { describe, test, expect } from 'vitest';
|
||||
import { StarRating } from './StarRating';
|
||||
|
||||
const toHtml = (StarRating as any).toHtml;
|
||||
|
||||
describe('StarRating.toHtml accessibility (F2.2)', () => {
|
||||
test('wrapper has role="img" and a "Rating: N out of maxStars" aria-label', () => {
|
||||
const { html } = toHtml({ rating: 4.5, maxStars: 5 }, '');
|
||||
expect(html).toMatch(/<span role="img" aria-label="Rating: 4\.5 out of 5"/);
|
||||
});
|
||||
|
||||
test('individual star glyphs are aria-hidden', () => {
|
||||
const { html } = toHtml({ rating: 3, maxStars: 5 }, '');
|
||||
const glyphs = html.match(/<i class="fa fa-star"[^>]*>/g) || [];
|
||||
expect(glyphs.length).toBeGreaterThan(0);
|
||||
glyphs.forEach((tag: string) => expect(tag).toContain('aria-hidden="true"'));
|
||||
});
|
||||
|
||||
test('respects custom maxStars in the aria-label', () => {
|
||||
const { html } = toHtml({ rating: 2, maxStars: 10 }, '');
|
||||
expect(html).toContain('aria-label="Rating: 2 out of 10"');
|
||||
});
|
||||
});
|
||||
|
||||
describe('StarRating.toHtml XSS hardening (filledColor/emptyColor/size into style=)', () => {
|
||||
test('a filledColor value containing a quote breakout is neutralized', () => {
|
||||
const malicious = '#f00" onmouseover="alert(1)';
|
||||
const { html } = toHtml({ rating: 3, maxStars: 5, filledColor: malicious }, '');
|
||||
expect(html).not.toMatch(/style="[^"]*"[^>]*onmouseover/);
|
||||
});
|
||||
|
||||
test('a size value containing </style><script> is neutralized', () => {
|
||||
const malicious = '24px</style><script>alert(1)</script>';
|
||||
const { html } = toHtml({ rating: 3, maxStars: 5, size: malicious }, '');
|
||||
expect(html).not.toContain('<script>alert(1)</script>');
|
||||
});
|
||||
|
||||
test('a normal filled color still renders', () => {
|
||||
const { html } = toHtml({ rating: 5, maxStars: 5, filledColor: '#ff9900' }, '');
|
||||
expect(html).toContain('color:#ff9900');
|
||||
});
|
||||
});
|
||||
|
||||
describe('StarRating.toHtml XSS hardening (rating/maxStars into aria-label, F2.2 CONFIRMED sink)', () => {
|
||||
test('a maxStars value with an attribute-breakout string is neutralized in aria-label', () => {
|
||||
const malicious = '5" onmouseover="alert(1)';
|
||||
const { html } = toHtml({ rating: 3, maxStars: malicious as any }, '');
|
||||
expect(html).not.toMatch(/onmouseover/);
|
||||
expect(html).not.toMatch(/aria-label="Rating: 3 out of 5" onmouseover/);
|
||||
});
|
||||
|
||||
test('a rating value with an attribute-breakout string is neutralized in aria-label', () => {
|
||||
const malicious = '4.5" onmouseover="alert(1)';
|
||||
const { html } = toHtml({ rating: malicious as any, maxStars: 5 }, '');
|
||||
expect(html).not.toMatch(/onmouseover/);
|
||||
});
|
||||
|
||||
test('a non-numeric maxStars does not blow up the star loop (no NaN glyph count, no huge output)', () => {
|
||||
const malicious = '5" onmouseover="alert(1)';
|
||||
const { html } = toHtml({ rating: 3, maxStars: malicious as any }, '');
|
||||
const glyphs = html.match(/<i class="fa fa-star"/g) || [];
|
||||
// Falls back to a sane default star count rather than looping 0 or NaN times.
|
||||
expect(glyphs.length).toBeGreaterThan(0);
|
||||
expect(glyphs.length).toBeLessThanOrEqual(50);
|
||||
});
|
||||
|
||||
test('an absurdly large maxStars is clamped to a sane maximum instead of looping unboundedly', () => {
|
||||
const { html } = toHtml({ rating: 3, maxStars: 1e9 as any }, '');
|
||||
const glyphs = html.match(/<i class="fa fa-star"/g) || [];
|
||||
expect(glyphs.length).toBeLessThanOrEqual(50);
|
||||
});
|
||||
|
||||
test('normal numeric rating/maxStars still render the expected aria-label', () => {
|
||||
const { html } = toHtml({ rating: 4.5, maxStars: 5 }, '');
|
||||
expect(html).toMatch(/<span role="img" aria-label="Rating: 4\.5 out of 5"/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('StarRating.craft.props includes the box-model/animation/visibility rollout props', () => {
|
||||
test('animation, animationDelay, and all 3 hideOn* flags are declared (blank/false defaults)', () => {
|
||||
const props = (StarRating as any).craft.props;
|
||||
expect(props).toHaveProperty('animation', '');
|
||||
expect(props).toHaveProperty('animationDelay', '');
|
||||
expect(props).toHaveProperty('hideOnDesktop', false);
|
||||
expect(props).toHaveProperty('hideOnTablet', false);
|
||||
expect(props).toHaveProperty('hideOnMobile', false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,157 @@
|
||||
import React, { CSSProperties } from 'react';
|
||||
import { useNode, UserComponent } from '@craftjs/core';
|
||||
import { cssPropsToString } from '../../utils/style-helpers';
|
||||
import { cssValue, escapeAttr } from '../../utils/escape';
|
||||
|
||||
interface StarRatingProps {
|
||||
rating?: number;
|
||||
maxStars?: number;
|
||||
size?: string;
|
||||
filledColor?: string;
|
||||
emptyColor?: string;
|
||||
style?: CSSProperties;
|
||||
animation?: string;
|
||||
animationDelay?: string;
|
||||
hideOnDesktop?: boolean;
|
||||
hideOnTablet?: boolean;
|
||||
hideOnMobile?: boolean;
|
||||
}
|
||||
|
||||
export const StarRating: UserComponent<StarRatingProps> = ({
|
||||
rating = 4.5,
|
||||
maxStars = 5,
|
||||
size = '24px',
|
||||
filledColor = '#f59e0b',
|
||||
emptyColor = '#d1d5db',
|
||||
style = {},
|
||||
}) => {
|
||||
const {
|
||||
connectors: { connect, drag },
|
||||
selected,
|
||||
} = useNode((node) => ({
|
||||
selected: node.events.selected,
|
||||
}));
|
||||
|
||||
const stars: React.ReactNode[] = [];
|
||||
for (let i = 1; i <= maxStars; i++) {
|
||||
if (i <= Math.floor(rating)) {
|
||||
// Full star
|
||||
stars.push(
|
||||
<i
|
||||
key={i}
|
||||
className="fa fa-star"
|
||||
style={{ color: filledColor, fontSize: size }}
|
||||
/>
|
||||
);
|
||||
} else if (i === Math.ceil(rating) && rating % 1 !== 0) {
|
||||
// Half star
|
||||
stars.push(
|
||||
<span key={i} style={{ position: 'relative', display: 'inline-block', fontSize: size }}>
|
||||
<i className="fa fa-star" style={{ color: emptyColor }} />
|
||||
<span style={{ position: 'absolute', left: 0, top: 0, overflow: 'hidden', width: '50%' }}>
|
||||
<i className="fa fa-star" style={{ color: filledColor }} />
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
} else {
|
||||
// Empty star
|
||||
stars.push(
|
||||
<i
|
||||
key={i}
|
||||
className="fa fa-star"
|
||||
style={{ color: emptyColor, fontSize: size }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<span
|
||||
ref={(ref: HTMLSpanElement | null): void => { if (ref) connect(drag(ref)); }}
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: '2px',
|
||||
outline: selected ? '2px solid #3b82f6' : 'none',
|
||||
...style,
|
||||
}}
|
||||
>
|
||||
{stars}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
/* ---------- Craft config ---------- */
|
||||
|
||||
StarRating.craft = {
|
||||
displayName: 'Star Rating',
|
||||
props: {
|
||||
rating: 4.5,
|
||||
maxStars: 5,
|
||||
size: '24px',
|
||||
filledColor: '#f59e0b',
|
||||
emptyColor: '#d1d5db',
|
||||
style: {},
|
||||
animation: '',
|
||||
animationDelay: '',
|
||||
hideOnDesktop: false,
|
||||
hideOnTablet: false,
|
||||
hideOnMobile: false,
|
||||
},
|
||||
rules: {
|
||||
canDrag: () => true,
|
||||
canMoveIn: () => false,
|
||||
canMoveOut: () => true,
|
||||
},
|
||||
};
|
||||
|
||||
/* ---------- HTML export ---------- */
|
||||
|
||||
(StarRating as any).toHtml = (props: StarRatingProps, _childrenHtml: string) => {
|
||||
// `rating`/`maxStars` are declared `number` in TS but arrive unchecked at
|
||||
// runtime (AI update_props only validates node_id; deserialized saved
|
||||
// state is untyped JSON) -- a string like `5" onmouseover="alert(1)`
|
||||
// breaks out of the aria-label attribute below, and an uncoerced/unclamped
|
||||
// maxStars can also blow up the star-glyph loop (NaN, absurd loop count,
|
||||
// or -- observed -- a RangeError from string concatenation overflow with
|
||||
// e.g. maxStars=1e9). Coerce to numbers with sane fallbacks/clamps first.
|
||||
const ratingRaw = Number(props.rating);
|
||||
const rating = Number.isFinite(ratingRaw) ? ratingRaw : 4.5;
|
||||
const maxStarsRaw = Number(props.maxStars);
|
||||
const maxStars = Number.isFinite(maxStarsRaw)
|
||||
? Math.min(Math.max(Math.trunc(maxStarsRaw), 0), 50)
|
||||
: 5;
|
||||
// Sanitized -- raw string-interpolation sinks in the star glyphs below.
|
||||
const size = cssValue(props.size) || '24px';
|
||||
const filledColor = cssValue(props.filledColor) || '#f59e0b';
|
||||
const emptyColor = cssValue(props.emptyColor) || '#d1d5db';
|
||||
const wrapperStyle = cssPropsToString({
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: '2px',
|
||||
...props.style,
|
||||
});
|
||||
|
||||
let starsHtml = '';
|
||||
for (let i = 1; i <= maxStars; i++) {
|
||||
if (i <= Math.floor(rating)) {
|
||||
starsHtml += `<i class="fa fa-star" style="color:${filledColor};font-size:${size}" aria-hidden="true"></i>`;
|
||||
} else if (i === Math.ceil(rating) && rating % 1 !== 0) {
|
||||
starsHtml += `<span style="position:relative;display:inline-block;font-size:${size}" aria-hidden="true"><i class="fa fa-star" style="color:${emptyColor}"></i><span style="position:absolute;left:0;top:0;overflow:hidden;width:50%"><i class="fa fa-star" style="color:${filledColor}"></i></span></span>`;
|
||||
} else {
|
||||
starsHtml += `<i class="fa fa-star" style="color:${emptyColor};font-size:${size}" aria-hidden="true"></i>`;
|
||||
}
|
||||
}
|
||||
|
||||
// The star glyphs convey nothing to assistive tech on their own -- wrap
|
||||
// in role="img" with a textual equivalent, and hide the decorative glyphs
|
||||
// themselves (aria-hidden above) so AT doesn't announce each icon.
|
||||
// Belt-and-suspenders: rating/maxStars are already coerced to numbers
|
||||
// above, but the assembled label is still run through escapeAttr() in
|
||||
// case a decimal/negative/Infinity edge case produces odd (though no
|
||||
// longer dangerous) text.
|
||||
const ariaLabel = escapeAttr(`Rating: ${rating} out of ${maxStars}`);
|
||||
return {
|
||||
html: `<span role="img" aria-label="${ariaLabel}"${wrapperStyle ? ` style="${wrapperStyle}"` : ''}>${starsHtml}</span>`,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,22 @@
|
||||
import { describe, test, expect } from 'vitest';
|
||||
import { TextBlock } from './TextBlock';
|
||||
|
||||
const toHtml = (TextBlock as any).toHtml;
|
||||
|
||||
describe('TextBlock.toHtml text escaping (attacker-controlled `text` prop)', () => {
|
||||
test('a tag-breakout attempt in text is neutralized (no injected element)', () => {
|
||||
const { html } = toHtml({ text: '</p><img src=x onerror=alert(1)>' }, '');
|
||||
expect(html).not.toContain('<img');
|
||||
expect(html).toContain('<img');
|
||||
});
|
||||
|
||||
test('ampersand is escaped for well-formed text content (consistency with escapeHtml)', () => {
|
||||
const { html } = toHtml({ text: 'Tom & Jerry' }, '');
|
||||
expect(html).toContain('Tom & Jerry');
|
||||
});
|
||||
|
||||
test('a normal text value still renders unchanged', () => {
|
||||
const { html } = toHtml({ text: 'Hello world' }, '');
|
||||
expect(html).toBe('<p>Hello world</p>');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,101 @@
|
||||
import React, { CSSProperties, useCallback, useRef, useEffect } from 'react';
|
||||
import { useNode, UserComponent } from '@craftjs/core';
|
||||
import { cssPropsToString } from '../../utils/style-helpers';
|
||||
import { escapeHtml } from '../../utils/escape';
|
||||
|
||||
interface TextBlockProps {
|
||||
text?: string;
|
||||
style?: CSSProperties;
|
||||
cssId?: string;
|
||||
cssClass?: string;
|
||||
hideOnDesktop?: boolean;
|
||||
hideOnTablet?: boolean;
|
||||
hideOnMobile?: boolean;
|
||||
animation?: string;
|
||||
animationDelay?: string;
|
||||
}
|
||||
|
||||
export const TextBlock: UserComponent<TextBlockProps> = ({
|
||||
text = 'Start typing here...',
|
||||
style = {},
|
||||
}) => {
|
||||
const {
|
||||
connectors: { connect, drag },
|
||||
selected,
|
||||
actions: { setProp },
|
||||
} = useNode((node) => ({
|
||||
selected: node.events.selected,
|
||||
}));
|
||||
|
||||
const elRef = useRef<HTMLParagraphElement | null>(null);
|
||||
const editedTextRef = useRef<string | null>(null);
|
||||
|
||||
const commitText = useCallback(() => {
|
||||
if (elRef.current) {
|
||||
const newText = elRef.current.innerText;
|
||||
editedTextRef.current = newText;
|
||||
setProp((p: TextBlockProps) => { p.text = newText; });
|
||||
}
|
||||
}, [setProp]);
|
||||
|
||||
const handleBlur = useCallback(() => { commitText(); }, [commitText]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selected && editedTextRef.current !== null) {
|
||||
setProp((p: TextBlockProps) => { p.text = editedTextRef.current!; });
|
||||
editedTextRef.current = null;
|
||||
}
|
||||
}, [selected, setProp]);
|
||||
|
||||
useEffect(() => {
|
||||
if (elRef.current && !selected && editedTextRef.current === null) {
|
||||
elRef.current.innerText = text || '';
|
||||
}
|
||||
}, [text, selected]);
|
||||
|
||||
return (
|
||||
<p
|
||||
ref={(ref: HTMLParagraphElement | null) => {
|
||||
elRef.current = ref;
|
||||
if (ref) connect(drag(ref));
|
||||
}}
|
||||
contentEditable={selected}
|
||||
suppressContentEditableWarning
|
||||
onBlur={handleBlur}
|
||||
onInput={() => { if (elRef.current) editedTextRef.current = elRef.current.innerText; }}
|
||||
style={{
|
||||
outline: 'none',
|
||||
cursor: selected ? 'text' : 'pointer',
|
||||
minHeight: '1em',
|
||||
...style,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
/* ---------- Craft config ---------- */
|
||||
|
||||
TextBlock.craft = {
|
||||
displayName: 'Text',
|
||||
props: {
|
||||
text: 'Start typing here...',
|
||||
style: {
|
||||
fontSize: '16px',
|
||||
lineHeight: '1.6',
|
||||
color: '#3f3f46',
|
||||
},
|
||||
},
|
||||
rules: {
|
||||
canDrag: () => true,
|
||||
canMoveIn: () => false,
|
||||
canMoveOut: () => true,
|
||||
},
|
||||
};
|
||||
|
||||
/* ---------- HTML export ---------- */
|
||||
|
||||
(TextBlock as any).toHtml = (props: TextBlockProps, _childrenHtml: string) => {
|
||||
const styleStr = cssPropsToString(props.style);
|
||||
const escapedText = escapeHtml(props.text || '');
|
||||
return { html: `<p${styleStr ? ` style="${styleStr}"` : ''}>${escapedText}</p>` };
|
||||
};
|
||||
@@ -0,0 +1,128 @@
|
||||
import { describe, test, expect } from 'vitest';
|
||||
import { ContactForm } from './ContactForm';
|
||||
|
||||
const toHtml = (ContactForm as any).toHtml;
|
||||
|
||||
describe('ContactForm.toHtml relay wiring', () => {
|
||||
test('with recipientEmail: emits marker, placeholder action, honeypot', () => {
|
||||
const { html } = toHtml({ recipientEmail: 'a@b.com', thankYouUrl: '/thx', fields: [] }, '');
|
||||
expect(html).toMatch(/<!--WHP-FORM id="F_[0-9a-z]+" recipient="a@b.com" thankyou="\/thx"-->/);
|
||||
expect(html).toMatch(/action="__WHP_FORM_ACTION__F_[0-9a-z]+__"/);
|
||||
expect(html).toContain('method="POST"');
|
||||
expect(html).toContain('name="_gotcha"');
|
||||
// marker id and action id match
|
||||
const mid = html.match(/id="(F_[0-9a-z]+)"/)![1];
|
||||
expect(html).toContain(`__WHP_FORM_ACTION__${mid}__`);
|
||||
});
|
||||
|
||||
test('without recipientEmail: no marker, falls back to formAction', () => {
|
||||
const { html } = toHtml({ formAction: '/legacy', fields: [] }, '');
|
||||
expect(html).not.toContain('WHP-FORM');
|
||||
expect(html).toContain('action="/legacy"');
|
||||
expect(html).not.toContain('_gotcha');
|
||||
// Backward-compat: ensure non-relay output is byte-identical (no extra blank lines from honeypot)
|
||||
expect(html).not.toMatch(/<form[^>]*>\n\s*\n/);
|
||||
});
|
||||
|
||||
test('without recipientEmail + real fields: byte-clean legacy output (realistic case)', () => {
|
||||
// The empty-fields case is NOT byte-identical to the old code (the old
|
||||
// template emitted a stray whitespace line when fields was empty; the new
|
||||
// ternary drops it). Real forms always have fields, so pin THAT scenario:
|
||||
// no marker, no honeypot, and no whitespace-only line between <form> and
|
||||
// the first field.
|
||||
const fields = [{ type: 'text', label: 'Name', name: 'name', placeholder: 'Your name', required: true }];
|
||||
const { html } = toHtml({ formAction: '/legacy', fields }, '');
|
||||
expect(html).not.toContain('WHP-FORM');
|
||||
expect(html).not.toContain('_gotcha');
|
||||
expect(html).toContain('action="/legacy"');
|
||||
expect(html).not.toMatch(/<form[^>]*>\n\s*\n/);
|
||||
// First field renders directly after the form tag (no stray blank line).
|
||||
expect(html).toMatch(/<form[^>]*>\n\s*<div/);
|
||||
expect(html).toContain('Name');
|
||||
});
|
||||
});
|
||||
|
||||
describe('ContactForm.toHtml successMessage', () => {
|
||||
// The published form-sender relay (form-sender/app/submit.php) delivers
|
||||
// success via a full-page 303 redirect to thankYouUrl or a hosted
|
||||
// thanks.php page -- there is no in-page JS to reveal an inline success
|
||||
// element. So successMessage is emitted as a forward-compatible data
|
||||
// attribute for a future AJAX/JS submission mode, not a live DOM element.
|
||||
test('with successMessage set: emits it as an escaped data attribute on the form', () => {
|
||||
const { html } = toHtml({ successMessage: "We'll be in touch!", fields: [] }, '');
|
||||
expect(html).toContain('data-whp-success-message="We'll be in touch!"');
|
||||
});
|
||||
|
||||
test('without successMessage: no data attribute emitted', () => {
|
||||
const { html } = toHtml({ fields: [] }, '');
|
||||
expect(html).not.toContain('data-whp-success-message');
|
||||
});
|
||||
|
||||
test('escapes attribute-breakout attempts in successMessage', () => {
|
||||
const { html } = toHtml({ successMessage: 'x" onerror="alert(1)', fields: [] }, '');
|
||||
expect(html).not.toContain('onerror="alert(1)"');
|
||||
});
|
||||
});
|
||||
|
||||
describe('ContactForm.toHtml relay marker deterministic + unique via node id (no Math.random)', () => {
|
||||
test('same node id -> identical marker+placeholder ids across two calls', () => {
|
||||
const { html: html1 } = toHtml({ recipientEmail: 'a@b.com', thankYouUrl: '/thx', fields: [] }, '', 'node-cf1');
|
||||
const { html: html2 } = toHtml({ recipientEmail: 'a@b.com', thankYouUrl: '/thx', fields: [] }, '', 'node-cf1');
|
||||
expect(html1).toBe(html2);
|
||||
});
|
||||
|
||||
test('marker id always equals the placeholder id it pairs with', () => {
|
||||
const { html } = toHtml({ recipientEmail: 'a@b.com', thankYouUrl: '/thx', fields: [] }, '', 'node-cf1');
|
||||
const mid = html.match(/<!--WHP-FORM id="([^"]+)"/)![1];
|
||||
expect(html).toContain(`action="__WHP_FORM_ACTION__${mid}__"`);
|
||||
});
|
||||
|
||||
test('two different node ids -> different fids', () => {
|
||||
const { html: html1 } = toHtml({ recipientEmail: 'a@b.com', thankYouUrl: '/thx', fields: [] }, '', 'node-cf1');
|
||||
const { html: html2 } = toHtml({ recipientEmail: 'a@b.com', thankYouUrl: '/thx', fields: [] }, '', 'node-cf2');
|
||||
const mid1 = html1.match(/<!--WHP-FORM id="([^"]+)"/)![1];
|
||||
const mid2 = html2.match(/<!--WHP-FORM id="([^"]+)"/)![1];
|
||||
expect(mid1).not.toBe(mid2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ContactForm.toHtml accessibility (F2.1)', () => {
|
||||
const fields = [
|
||||
{ type: 'text' as const, label: 'Name', name: 'name', placeholder: 'Your name', required: true },
|
||||
{ type: 'email' as const, label: 'Email', name: 'email', placeholder: 'you@example.com', required: true },
|
||||
];
|
||||
|
||||
test('each field label for= matches its control id=, and ids are unique', () => {
|
||||
const { html } = toHtml({ fields }, '');
|
||||
const labelIds = [...html.matchAll(/<label for="([^"]+)"/g)].map((m) => m[1]);
|
||||
const controlIds = [...html.matchAll(/<(?:input|textarea|select) id="([^"]+)"/g)].map((m) => m[1]);
|
||||
expect(labelIds.length).toBe(2);
|
||||
expect(controlIds.length).toBe(2);
|
||||
expect(labelIds).toEqual(controlIds);
|
||||
expect(new Set(controlIds).size).toBe(2);
|
||||
});
|
||||
|
||||
test('ids are deterministic across repeated calls with the same fields', () => {
|
||||
const { html: html1 } = toHtml({ fields }, '');
|
||||
const { html: html2 } = toHtml({ fields }, '');
|
||||
const ids1 = [...html1.matchAll(/<input id="([^"]+)"/g)].map((m) => m[1]);
|
||||
const ids2 = [...html2.matchAll(/<input id="([^"]+)"/g)].map((m) => m[1]);
|
||||
expect(ids1).toEqual(ids2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ContactForm.toHtml field type attribute sanitization', () => {
|
||||
test('malicious field.type cannot break out of the input attribute; falls back to type="text"', () => {
|
||||
const fields = [{ type: 'text"><img src=x onerror=alert(1)>' as any, label: 'Name', name: 'name', placeholder: 'Your name', required: false }];
|
||||
const { html } = toHtml({ fields }, '');
|
||||
expect(html).not.toContain('<img');
|
||||
expect(html).not.toContain('onerror=');
|
||||
expect(html).toContain('type="text"');
|
||||
});
|
||||
|
||||
test('legitimate email field type still passes through unchanged', () => {
|
||||
const fields = [{ type: 'email' as const, label: 'Email', name: 'email', placeholder: 'you@example.com', required: false }];
|
||||
const { html } = toHtml({ fields }, '');
|
||||
expect(html).toContain('type="email"');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,231 @@
|
||||
import React, { CSSProperties } from 'react';
|
||||
import { useNode, UserComponent } from '@craftjs/core';
|
||||
import { cssPropsToString } from '../../utils/style-helpers';
|
||||
import { relayFormWiring } from '../../utils/form-relay-wiring';
|
||||
import { escapeHtml, escapeAttr, slugId, cssValue, sanitizeInputType } from '../../utils/escape';
|
||||
|
||||
interface ContactFormField {
|
||||
type: 'text' | 'email' | 'tel' | 'textarea' | 'select';
|
||||
label: string;
|
||||
name: string;
|
||||
placeholder: string;
|
||||
required: boolean;
|
||||
options?: string[];
|
||||
}
|
||||
|
||||
interface ContactFormProps {
|
||||
fields?: ContactFormField[];
|
||||
submitText?: string;
|
||||
submitColor?: string;
|
||||
formAction?: string;
|
||||
successMessage?: string;
|
||||
style?: CSSProperties;
|
||||
labelColor?: string;
|
||||
inputBg?: string;
|
||||
inputBorder?: string;
|
||||
recipientEmail?: string;
|
||||
thankYouUrl?: string;
|
||||
}
|
||||
|
||||
const defaultFields: ContactFormField[] = [
|
||||
{ type: 'text', label: 'Name', name: 'name', placeholder: 'Your name', required: true },
|
||||
{ type: 'email', label: 'Email', name: 'email', placeholder: 'your@email.com', required: true },
|
||||
{ type: 'tel', label: 'Phone', name: 'phone', placeholder: '(555) 123-4567', required: false },
|
||||
{ type: 'textarea', label: 'Message', name: 'message', placeholder: 'How can we help you?', required: true },
|
||||
];
|
||||
|
||||
export const ContactForm: UserComponent<ContactFormProps> = ({
|
||||
fields = defaultFields,
|
||||
submitText = 'Send Message',
|
||||
submitColor = '#3b82f6',
|
||||
formAction = '#',
|
||||
successMessage = 'Thank you! We\'ll get back to you soon.',
|
||||
style = {},
|
||||
labelColor = '#374151',
|
||||
inputBg = '#ffffff',
|
||||
inputBorder = '#d1d5db',
|
||||
}) => {
|
||||
const {
|
||||
connectors: { connect, drag },
|
||||
selected,
|
||||
} = useNode((node) => ({
|
||||
selected: node.events.selected,
|
||||
}));
|
||||
|
||||
const inputBaseStyle: CSSProperties = {
|
||||
width: '100%',
|
||||
padding: '10px 14px',
|
||||
fontSize: '14px',
|
||||
fontFamily: 'Inter, sans-serif',
|
||||
border: `1px solid ${inputBorder}`,
|
||||
borderRadius: '6px',
|
||||
backgroundColor: inputBg,
|
||||
color: '#1f2937',
|
||||
boxSizing: 'border-box',
|
||||
outline: 'none',
|
||||
};
|
||||
|
||||
return (
|
||||
<form
|
||||
ref={(ref: HTMLFormElement | null): void => { if (ref) connect(drag(ref)); }}
|
||||
action={formAction}
|
||||
method="POST"
|
||||
onSubmit={(e) => e.preventDefault()}
|
||||
style={{
|
||||
padding: '32px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '20px',
|
||||
outline: selected ? '2px solid #3b82f6' : 'none',
|
||||
...style,
|
||||
}}
|
||||
>
|
||||
{fields.map((field, i) => (
|
||||
<div key={i} style={{ display: 'flex', flexDirection: 'column', gap: '6px' }}>
|
||||
<label style={{ fontSize: '14px', fontWeight: '500', color: labelColor }}>
|
||||
{field.label}
|
||||
{field.required && <span style={{ color: '#ef4444', marginLeft: '2px' }}>*</span>}
|
||||
</label>
|
||||
{field.type === 'textarea' ? (
|
||||
<textarea
|
||||
name={field.name}
|
||||
placeholder={field.placeholder}
|
||||
required={field.required}
|
||||
rows={4}
|
||||
style={{ ...inputBaseStyle, resize: 'vertical' }}
|
||||
/>
|
||||
) : field.type === 'select' ? (
|
||||
<select
|
||||
name={field.name}
|
||||
required={field.required}
|
||||
style={{ ...inputBaseStyle, cursor: 'pointer' }}
|
||||
>
|
||||
<option value="">{field.placeholder || 'Select...'}</option>
|
||||
{(field.options || []).map((opt, j) => (
|
||||
<option key={j} value={opt}>{opt}</option>
|
||||
))}
|
||||
</select>
|
||||
) : (
|
||||
<input
|
||||
type={field.type}
|
||||
name={field.name}
|
||||
placeholder={field.placeholder}
|
||||
required={field.required}
|
||||
style={inputBaseStyle}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
<button
|
||||
type="submit"
|
||||
style={{
|
||||
padding: '12px 32px',
|
||||
fontSize: '16px',
|
||||
fontWeight: '600',
|
||||
fontFamily: 'Inter, sans-serif',
|
||||
color: '#ffffff',
|
||||
backgroundColor: submitColor,
|
||||
border: 'none',
|
||||
borderRadius: '8px',
|
||||
cursor: 'pointer',
|
||||
alignSelf: 'flex-start',
|
||||
}}
|
||||
>
|
||||
{submitText}
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
/* ---------- Craft config ---------- */
|
||||
|
||||
ContactForm.craft = {
|
||||
displayName: 'Contact Form',
|
||||
props: {
|
||||
fields: defaultFields,
|
||||
submitText: 'Send Message',
|
||||
submitColor: '#3b82f6',
|
||||
formAction: '#',
|
||||
successMessage: 'Thank you! We\'ll get back to you soon.',
|
||||
style: {
|
||||
backgroundColor: '#ffffff',
|
||||
borderRadius: '12px',
|
||||
border: '1px solid #e5e7eb',
|
||||
},
|
||||
labelColor: '#374151',
|
||||
inputBg: '#ffffff',
|
||||
inputBorder: '#d1d5db',
|
||||
recipientEmail: '',
|
||||
thankYouUrl: '',
|
||||
},
|
||||
rules: {
|
||||
canDrag: () => true,
|
||||
canMoveIn: () => false,
|
||||
canMoveOut: () => true,
|
||||
},
|
||||
};
|
||||
|
||||
/* ---------- HTML export ---------- */
|
||||
|
||||
(ContactForm as any).toHtml = (props: ContactFormProps, _childrenHtml: string, nodeId?: string) => {
|
||||
const formStyle = cssPropsToString({
|
||||
padding: '32px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '20px',
|
||||
...props.style,
|
||||
});
|
||||
// Sanitized -- raw string-interpolation sinks in inputStyleStr/labelHtml
|
||||
// below.
|
||||
const labelColor = cssValue(props.labelColor) || '#374151';
|
||||
const inputBg = cssValue(props.inputBg) || '#ffffff';
|
||||
const inputBorder = cssValue(props.inputBorder) || '#d1d5db';
|
||||
const inputStyleStr = `width:100%;padding:10px 14px;font-size:14px;font-family:Inter,sans-serif;border:1px solid ${inputBorder};border-radius:6px;background-color:${inputBg};color:#1f2937;box-sizing:border-box;outline:none`;
|
||||
|
||||
const fieldsHtml = (props.fields || defaultFields).map((field, i) => {
|
||||
const reqStar = field.required ? '<span style="color:#ef4444;margin-left:2px">*</span>' : '';
|
||||
// Deterministic id: index + slugified name, so repeated fields with the
|
||||
// same name (or no name) still get unique, stable ids -- no Math.random.
|
||||
const fieldId = `field-${i}-${slugId(field.name)}`;
|
||||
const labelHtml = `<label for="${escapeAttr(fieldId)}" style="font-size:14px;font-weight:500;color:${labelColor}">${escapeHtml(field.label)}${reqStar}</label>`;
|
||||
const reqAttr = field.required ? ' required' : '';
|
||||
let inputHtml = '';
|
||||
if (field.type === 'textarea') {
|
||||
inputHtml = `<textarea id="${escapeAttr(fieldId)}" name="${escapeAttr(field.name)}" placeholder="${escapeAttr(field.placeholder)}" rows="4" style="${inputStyleStr};resize:vertical"${reqAttr}></textarea>`;
|
||||
} else if (field.type === 'select') {
|
||||
const opts = (field.options || []).map((o) => `<option value="${escapeAttr(o)}">${escapeHtml(o)}</option>`).join('');
|
||||
inputHtml = `<select id="${escapeAttr(fieldId)}" name="${escapeAttr(field.name)}" style="${inputStyleStr};cursor:pointer"${reqAttr}><option value="">${escapeHtml(field.placeholder || 'Select...')}</option>${opts}</select>`;
|
||||
} else {
|
||||
inputHtml = `<input id="${escapeAttr(fieldId)}" type="${sanitizeInputType(field.type)}" name="${escapeAttr(field.name)}" placeholder="${escapeAttr(field.placeholder)}" style="${inputStyleStr}"${reqAttr} />`;
|
||||
}
|
||||
return `<div style="display:flex;flex-direction:column;gap:6px">${labelHtml}${inputHtml}</div>`;
|
||||
}).join('\n ');
|
||||
|
||||
const btnStyle = cssPropsToString({
|
||||
padding: '12px 32px',
|
||||
fontSize: '16px',
|
||||
fontWeight: '600',
|
||||
fontFamily: 'Inter, sans-serif',
|
||||
color: '#ffffff',
|
||||
backgroundColor: props.submitColor || '#3b82f6',
|
||||
border: 'none',
|
||||
borderRadius: '8px',
|
||||
cursor: 'pointer',
|
||||
alignSelf: 'flex-start',
|
||||
});
|
||||
|
||||
const { marker, actionAttr, honeypot } = relayFormWiring(props.recipientEmail, props.thankYouUrl, props.formAction, nodeId);
|
||||
|
||||
// The form-sender relay delivers success via a full-page 303 redirect
|
||||
// (to thankYouUrl or a hosted thanks.php page) -- there is no in-page JS
|
||||
// that reveals an inline success element today. Emit successMessage as a
|
||||
// forward-compatible data attribute so a future AJAX/JS submission mode
|
||||
// can read it, without implying a live mechanism that doesn't exist yet.
|
||||
const successAttr = props.successMessage ? ` data-whp-success-message="${escapeAttr(props.successMessage)}"` : '';
|
||||
|
||||
return {
|
||||
html: `${marker}<form action="${actionAttr}" method="POST"${successAttr}${formStyle ? ` style="${formStyle}"` : ''}>
|
||||
${honeypot ? ` ${honeypot}\n` : ''}${fieldsHtml ? ` ${fieldsHtml}\n` : ''} <button type="submit"${btnStyle ? ` style="${btnStyle}"` : ''}>${escapeHtml(props.submitText || 'Send Message')}</button>
|
||||
</form>`,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,25 @@
|
||||
import { describe, test, expect } from 'vitest';
|
||||
import { FormButton } from './FormButton';
|
||||
|
||||
const toHtml = (FormButton as any).toHtml;
|
||||
|
||||
describe('FormButton.toHtml', () => {
|
||||
test('normal text renders as-is', () => {
|
||||
const { html } = toHtml({ text: 'Send it' }, '');
|
||||
expect(html).toContain('>Send it<');
|
||||
expect(html).toContain('type="submit"');
|
||||
});
|
||||
|
||||
test('type="submit" is a hardcoded literal, not prop-driven', () => {
|
||||
const { html } = toHtml({ text: 'Submit' }, '');
|
||||
expect(html).toMatch(/<button type="submit"/);
|
||||
});
|
||||
|
||||
test('text content is escaped for <, >, &, and " (consistent with escapeHtml)', () => {
|
||||
const { html } = toHtml({ text: '<script>alert(1)</script> & "quoted"' }, '');
|
||||
expect(html).not.toContain('<script>');
|
||||
expect(html).toContain('<script>alert(1)</script>');
|
||||
expect(html).toContain('&');
|
||||
expect(html).toContain('"quoted"');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,82 @@
|
||||
import React, { CSSProperties } from 'react';
|
||||
import { useNode, UserComponent } from '@craftjs/core';
|
||||
import { cssPropsToString } from '../../utils/style-helpers';
|
||||
import { escapeHtml } from '../../utils/escape';
|
||||
|
||||
interface FormButtonProps {
|
||||
text?: string;
|
||||
style?: CSSProperties;
|
||||
}
|
||||
|
||||
export const FormButton: UserComponent<FormButtonProps> = ({
|
||||
text = 'Submit',
|
||||
style = {},
|
||||
}) => {
|
||||
const {
|
||||
connectors: { connect, drag },
|
||||
selected,
|
||||
} = useNode((node) => ({
|
||||
selected: node.events.selected,
|
||||
}));
|
||||
|
||||
return (
|
||||
<button
|
||||
ref={(ref: HTMLButtonElement | null): void => { if (ref) connect(drag(ref)); }}
|
||||
type="submit"
|
||||
onClick={(e) => e.preventDefault()}
|
||||
style={{
|
||||
padding: '12px 32px',
|
||||
backgroundColor: '#3b82f6',
|
||||
color: '#ffffff',
|
||||
border: 'none',
|
||||
borderRadius: '6px',
|
||||
fontSize: '16px',
|
||||
fontWeight: '600',
|
||||
cursor: 'pointer',
|
||||
outline: selected ? '2px solid #3b82f6' : 'none',
|
||||
outlineOffset: selected ? '2px' : '0',
|
||||
...style,
|
||||
}}
|
||||
>
|
||||
{text}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
/* ---------- Craft config ---------- */
|
||||
|
||||
FormButton.craft = {
|
||||
displayName: 'Submit Button',
|
||||
props: {
|
||||
text: 'Submit',
|
||||
style: {
|
||||
backgroundColor: '#3b82f6',
|
||||
color: '#ffffff',
|
||||
padding: '12px 32px',
|
||||
borderRadius: '6px',
|
||||
fontWeight: '600',
|
||||
fontSize: '16px',
|
||||
border: 'none',
|
||||
},
|
||||
},
|
||||
rules: {
|
||||
canDrag: () => true,
|
||||
canMoveIn: () => false,
|
||||
canMoveOut: () => true,
|
||||
},
|
||||
};
|
||||
|
||||
/* ---------- HTML export ---------- */
|
||||
|
||||
(FormButton as any).toHtml = (props: FormButtonProps, _childrenHtml: string) => {
|
||||
const styleStr = cssPropsToString({
|
||||
padding: '12px 32px',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
...props.style,
|
||||
});
|
||||
const escapedText = escapeHtml(props.text || 'Submit');
|
||||
return {
|
||||
html: `<button type="submit"${styleStr ? ` style="${styleStr}"` : ''}>${escapedText}</button>`,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,54 @@
|
||||
import { describe, test, expect } from 'vitest';
|
||||
import { FormContainer } from './FormContainer';
|
||||
|
||||
const toHtml = (FormContainer as any).toHtml;
|
||||
|
||||
describe('FormContainer.toHtml relay wiring', () => {
|
||||
test('with recipientEmail: marker + placeholder action + honeypot, forces POST', () => {
|
||||
const { html } = toHtml({ recipientEmail: 'a@b.com', thankYouUrl: '/thx', method: 'GET' }, '<input name="email">');
|
||||
expect(html).toMatch(/<!--WHP-FORM id="F_[0-9a-z]+" recipient="a@b.com" thankyou="\/thx"-->/);
|
||||
expect(html).toMatch(/action="__WHP_FORM_ACTION__F_[0-9a-z]+__"/);
|
||||
expect(html).toContain('method="POST"'); // relay forces POST even though method=GET
|
||||
expect(html).toContain('name="_gotcha"');
|
||||
// honeypot precedes the form's children
|
||||
expect(html.indexOf('_gotcha')).toBeLessThan(html.indexOf('name="email"'));
|
||||
// marker id === action id
|
||||
const mid = html.match(/id="(F_[0-9a-z]+)"/)![1];
|
||||
expect(html).toContain(`__WHP_FORM_ACTION__${mid}__`);
|
||||
});
|
||||
|
||||
test('without recipientEmail: legacy action/method, no marker or honeypot', () => {
|
||||
const { html } = toHtml({ action: '/legacy', method: 'POST' }, '<input name="email">');
|
||||
expect(html).not.toContain('WHP-FORM');
|
||||
expect(html).not.toContain('_gotcha');
|
||||
expect(html).toContain('action="/legacy"');
|
||||
expect(html).toContain('<input name="email">');
|
||||
});
|
||||
|
||||
test('same node id -> identical marker+placeholder ids across two calls', () => {
|
||||
const { html: html1 } = toHtml({ recipientEmail: 'a@b.com', thankYouUrl: '/thx' }, '<input name="email">', 'node-fc1');
|
||||
const { html: html2 } = toHtml({ recipientEmail: 'a@b.com', thankYouUrl: '/thx' }, '<input name="email">', 'node-fc1');
|
||||
expect(html1).toBe(html2);
|
||||
});
|
||||
|
||||
test('two different node ids -> different fids', () => {
|
||||
const { html: html1 } = toHtml({ recipientEmail: 'a@b.com', thankYouUrl: '/thx' }, '<input name="email">', 'node-fc1');
|
||||
const { html: html2 } = toHtml({ recipientEmail: 'a@b.com', thankYouUrl: '/thx' }, '<input name="email">', 'node-fc2');
|
||||
const mid1 = html1.match(/<!--WHP-FORM id="([^"]+)"/)![1];
|
||||
const mid2 = html2.match(/<!--WHP-FORM id="([^"]+)"/)![1];
|
||||
expect(mid1).not.toBe(mid2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('FormContainer.toHtml method attribute sanitization', () => {
|
||||
test('malicious method value cannot break out of the attribute; falls back to POST', () => {
|
||||
const { html } = toHtml({ action: '/legacy', method: 'POST"><script>alert(1)</script>' }, '');
|
||||
expect(html).not.toContain('<script');
|
||||
expect(html).toContain('method="POST"');
|
||||
});
|
||||
|
||||
test('legitimate GET method still passes through unchanged (non-relay path)', () => {
|
||||
const { html } = toHtml({ action: '/legacy', method: 'GET' }, '');
|
||||
expect(html).toContain('method="GET"');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
import React, { CSSProperties } from 'react';
|
||||
import { useNode, Element, UserComponent } from '@craftjs/core';
|
||||
import { Container } from '../layout/Container';
|
||||
import { cssPropsToString } from '../../utils/style-helpers';
|
||||
import { relayFormWiring } from '../../utils/form-relay-wiring';
|
||||
import { sanitizeFormMethod } from '../../utils/escape';
|
||||
|
||||
interface FormContainerProps {
|
||||
action?: string;
|
||||
method?: 'GET' | 'POST';
|
||||
recipientEmail?: string;
|
||||
thankYouUrl?: string;
|
||||
style?: CSSProperties;
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
export const FormContainer: UserComponent<FormContainerProps> = ({
|
||||
action = '#',
|
||||
method = 'POST',
|
||||
style = {},
|
||||
}) => {
|
||||
const { connectors: { connect, drag } } = useNode();
|
||||
|
||||
return (
|
||||
<form
|
||||
ref={(ref: HTMLFormElement | null): void => { if (ref) connect(drag(ref)); }}
|
||||
action={action}
|
||||
method={method}
|
||||
onSubmit={(e) => e.preventDefault()}
|
||||
style={{
|
||||
padding: '24px',
|
||||
minHeight: '80px',
|
||||
...style,
|
||||
}}
|
||||
>
|
||||
<Element
|
||||
id="form-inner"
|
||||
is={Container}
|
||||
canvas
|
||||
style={{ display: 'flex', flexDirection: 'column', gap: '16px', padding: '0' }}
|
||||
tag="div"
|
||||
/>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
/* ---------- Craft config ---------- */
|
||||
|
||||
FormContainer.craft = {
|
||||
displayName: 'Form',
|
||||
props: {
|
||||
action: '#',
|
||||
method: 'POST',
|
||||
recipientEmail: '',
|
||||
thankYouUrl: '',
|
||||
style: {
|
||||
padding: '24px',
|
||||
backgroundColor: '#ffffff',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid #e4e4e7',
|
||||
},
|
||||
},
|
||||
rules: {
|
||||
canDrag: () => true,
|
||||
canMoveIn: () => false,
|
||||
canMoveOut: () => true,
|
||||
},
|
||||
};
|
||||
|
||||
/* ---------- HTML export ---------- */
|
||||
|
||||
(FormContainer as any).toHtml = (props: FormContainerProps, childrenHtml: string, nodeId?: string) => {
|
||||
const styleStr = cssPropsToString({
|
||||
padding: '24px',
|
||||
...props.style,
|
||||
});
|
||||
const { useRelay, marker, actionAttr, honeypot } = relayFormWiring(props.recipientEmail, props.thankYouUrl, props.action, nodeId);
|
||||
const method = useRelay ? 'POST' : sanitizeFormMethod(props.method); // relay requires POST
|
||||
const body = honeypot + childrenHtml; // honeypot as first child
|
||||
return {
|
||||
html: `${marker}<form action="${actionAttr}" method="${method}"${styleStr ? ` style="${styleStr}"` : ''}>${body}</form>`,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,74 @@
|
||||
import { describe, test, expect } from 'vitest';
|
||||
import { InputField } from './InputField';
|
||||
|
||||
const toHtml = (InputField as any).toHtml;
|
||||
|
||||
describe('InputField.toHtml accessibility (F2.1)', () => {
|
||||
test('label for= matches input id=', () => {
|
||||
const { html } = toHtml({ label: 'Your Name', name: 'name' }, '');
|
||||
const forMatch = html.match(/<label for="([^"]+)"/);
|
||||
const idMatch = html.match(/<input id="([^"]+)"/);
|
||||
expect(forMatch).toBeTruthy();
|
||||
expect(idMatch).toBeTruthy();
|
||||
expect(forMatch![1]).toBe(idMatch![1]);
|
||||
});
|
||||
|
||||
test('id is deterministic (derived from name, not random) -- stable across calls', () => {
|
||||
const { html: html1 } = toHtml({ label: 'Email', name: 'email' }, '');
|
||||
const { html: html2 } = toHtml({ label: 'Email', name: 'email' }, '');
|
||||
const id1 = html1.match(/<input id="([^"]+)"/)![1];
|
||||
const id2 = html2.match(/<input id="([^"]+)"/)![1];
|
||||
expect(id1).toBe(id2);
|
||||
});
|
||||
|
||||
test('no visible label: input gets aria-label from placeholder', () => {
|
||||
const { html } = toHtml({ label: '', name: 'phone', placeholder: 'Phone number' }, '');
|
||||
expect(html).not.toContain('<label');
|
||||
expect(html).toContain('aria-label="Phone number"');
|
||||
});
|
||||
});
|
||||
|
||||
describe('InputField.toHtml deterministic + unique ids (thread node id, resolves id-collision finding)', () => {
|
||||
test('label for= still matches input id= after threading the node id', () => {
|
||||
const { html } = toHtml({ label: 'Your Name', name: 'name' }, '', 'node-in1');
|
||||
const forMatch = html.match(/<label for="([^"]+)"/);
|
||||
const idMatch = html.match(/<input id="([^"]+)"/);
|
||||
expect(forMatch![1]).toBe(idMatch![1]);
|
||||
});
|
||||
|
||||
test('same node id -> identical output across calls (deterministic, no Math.random)', () => {
|
||||
const { html: html1 } = toHtml({ label: 'Name', name: 'name' }, '', 'node-in1');
|
||||
const { html: html2 } = toHtml({ label: 'Name', name: 'name' }, '', 'node-in1');
|
||||
expect(html1).toBe(html2);
|
||||
});
|
||||
|
||||
test('two instances with the SAME default name but different node ids do not collide', () => {
|
||||
const { html: html1 } = toHtml({ label: 'Your Name', name: 'name' }, '', 'node-in1');
|
||||
const { html: html2 } = toHtml({ label: 'Your Name', name: 'name' }, '', 'node-in2');
|
||||
const id1 = html1.match(/<input id="([^"]+)"/)![1];
|
||||
const id2 = html2.match(/<input id="([^"]+)"/)![1];
|
||||
expect(id1).not.toBe(id2);
|
||||
});
|
||||
|
||||
test('no nodeId (legacy 2-arg call): id derivation stays deterministic, not random', () => {
|
||||
const { html: html1 } = toHtml({ label: 'Email', name: 'email' }, '');
|
||||
const { html: html2 } = toHtml({ label: 'Email', name: 'email' }, '');
|
||||
const id1 = html1.match(/<input id="([^"]+)"/)![1];
|
||||
const id2 = html2.match(/<input id="([^"]+)"/)![1];
|
||||
expect(id1).toBe(id2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('InputField.toHtml type attribute sanitization', () => {
|
||||
test('malicious type value cannot break out of the attribute; falls back to type="text"', () => {
|
||||
const { html } = toHtml({ label: 'Name', name: 'name', type: 'text" autofocus onfocus="alert(1)' as any }, '');
|
||||
expect(html).not.toContain('onfocus=');
|
||||
expect(html).not.toContain('autofocus');
|
||||
expect(html).toContain('type="text"');
|
||||
});
|
||||
|
||||
test('legitimate number type still passes through unchanged', () => {
|
||||
const { html } = toHtml({ label: 'Age', name: 'age', type: 'number' as const }, '');
|
||||
expect(html).toContain('type="number"');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,117 @@
|
||||
import React, { CSSProperties } from 'react';
|
||||
import { useNode, UserComponent } from '@craftjs/core';
|
||||
import { cssPropsToString } from '../../utils/style-helpers';
|
||||
import { escapeHtml, escapeAttr, scopeId, sanitizeInputType } from '../../utils/escape';
|
||||
|
||||
interface InputFieldProps {
|
||||
label?: string;
|
||||
type?: 'text' | 'email' | 'password' | 'number' | 'tel' | 'url';
|
||||
name?: string;
|
||||
placeholder?: string;
|
||||
required?: boolean;
|
||||
style?: CSSProperties;
|
||||
}
|
||||
|
||||
export const InputField: UserComponent<InputFieldProps> = ({
|
||||
label = 'Label',
|
||||
type = 'text',
|
||||
name = 'field',
|
||||
placeholder = '',
|
||||
required = false,
|
||||
style = {},
|
||||
}) => {
|
||||
const {
|
||||
connectors: { connect, drag },
|
||||
selected,
|
||||
} = useNode((node) => ({
|
||||
selected: node.events.selected,
|
||||
}));
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={(ref: HTMLElement | null): void => { if (ref) connect(drag(ref)); }}
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '4px',
|
||||
outline: selected ? '2px solid #3b82f6' : 'none',
|
||||
borderRadius: '4px',
|
||||
...style,
|
||||
}}
|
||||
>
|
||||
{label && (
|
||||
<label style={{ fontSize: '14px', fontWeight: '500', color: '#18181b' }}>
|
||||
{label}{required && <span style={{ color: '#ef4444' }}> *</span>}
|
||||
</label>
|
||||
)}
|
||||
<input
|
||||
type={type}
|
||||
name={name}
|
||||
placeholder={placeholder}
|
||||
required={required}
|
||||
style={{
|
||||
padding: '10px 12px',
|
||||
border: '1px solid #d4d4d8',
|
||||
borderRadius: '6px',
|
||||
fontSize: '14px',
|
||||
color: '#18181b',
|
||||
backgroundColor: '#ffffff',
|
||||
outline: 'none',
|
||||
width: '100%',
|
||||
boxSizing: 'border-box',
|
||||
}}
|
||||
readOnly
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/* ---------- Craft config ---------- */
|
||||
|
||||
InputField.craft = {
|
||||
displayName: 'Input',
|
||||
props: {
|
||||
label: 'Your Name',
|
||||
type: 'text',
|
||||
name: 'name',
|
||||
placeholder: 'Enter your name',
|
||||
required: false,
|
||||
style: {},
|
||||
},
|
||||
rules: {
|
||||
canDrag: () => true,
|
||||
canMoveIn: () => false,
|
||||
canMoveOut: () => true,
|
||||
},
|
||||
};
|
||||
|
||||
/* ---------- HTML export ---------- */
|
||||
|
||||
(InputField as any).toHtml = (props: InputFieldProps, _childrenHtml: string, nodeId?: string) => {
|
||||
const wrapStyle = cssPropsToString({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '4px',
|
||||
...props.style,
|
||||
});
|
||||
const reqAttr = props.required ? ' required' : '';
|
||||
// Deterministic AND unique id: scoped on the Craft node id so the
|
||||
// <label for> always matches the <input id> AND two InputField instances
|
||||
// that share the same (often default) `name` -- e.g. two untouched
|
||||
// "Input" blocks both named "name" -- don't collide on `field-name` and
|
||||
// clobber each other's for/id wiring. Falls back to the old name-derived
|
||||
// hash for legacy 2-arg call sites without a node id.
|
||||
const fieldId = scopeId(nodeId, props.name || 'field', 'field');
|
||||
const labelHtml = props.label
|
||||
? `<label for="${escapeAttr(fieldId)}" style="font-size:14px;font-weight:500;color:#18181b">${escapeHtml(props.label)}${props.required ? '<span style="color:#ef4444"> *</span>' : ''}</label>`
|
||||
: '';
|
||||
const ariaLabelAttr = !props.label
|
||||
? ` aria-label="${escapeAttr(props.placeholder || props.name || 'Input field')}"`
|
||||
: '';
|
||||
return {
|
||||
html: `<div${wrapStyle ? ` style="${wrapStyle}"` : ''}>
|
||||
${labelHtml}
|
||||
<input id="${escapeAttr(fieldId)}" type="${sanitizeInputType(props.type)}" name="${escapeAttr(props.name || 'field')}" placeholder="${escapeAttr(props.placeholder || '')}"${reqAttr}${ariaLabelAttr} style="padding:10px 12px;border:1px solid #d4d4d8;border-radius:6px;font-size:14px;color:#18181b;background-color:#ffffff;width:100%;box-sizing:border-box" />
|
||||
</div>`,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,39 @@
|
||||
import { describe, test, expect } from 'vitest';
|
||||
import { SubscribeForm } from './SubscribeForm';
|
||||
|
||||
const toHtml = (SubscribeForm as any).toHtml;
|
||||
|
||||
describe('SubscribeForm.toHtml hardcoded attributes stay hardcoded (no raw prop breakout)', () => {
|
||||
test('form method is always POST regardless of any injected props', () => {
|
||||
const { html } = toHtml({ heading: 'Join us', method: 'GET"><script>alert(1)</script>' } as any, '');
|
||||
expect(html).toContain('<form method="POST"');
|
||||
expect(html).not.toContain('<script');
|
||||
});
|
||||
|
||||
test('email input type is always "email" regardless of any injected props', () => {
|
||||
const { html } = toHtml({ type: 'text"><img src=x onerror=alert(1)>' } as any, '');
|
||||
expect(html).toContain('<input type="email"');
|
||||
expect(html).not.toContain('<img');
|
||||
expect(html).not.toContain('onerror=');
|
||||
});
|
||||
|
||||
test('layout enum only ever feeds one of two fixed literal style strings, never raw', () => {
|
||||
const { html: inlineHtml } = toHtml({ layout: 'inline' }, '');
|
||||
const { html: stackedHtml } = toHtml({ layout: 'stacked' }, '');
|
||||
expect(inlineHtml).toContain('flex-direction:row');
|
||||
expect(stackedHtml).toContain('flex-direction:column');
|
||||
});
|
||||
|
||||
test('malicious layout value cannot inject raw CSS/attribute breakout (falls through the isInline boolean check to the stacked literal)', () => {
|
||||
const { html } = toHtml({ layout: '"><script>alert(1)</script>' as any }, '');
|
||||
expect(html).not.toContain('<script');
|
||||
expect(html).toContain('flex-direction:column');
|
||||
});
|
||||
|
||||
test('normal render still produces expected structure', () => {
|
||||
const { html } = toHtml({ heading: 'Subscribe', placeholder: 'you@example.com', buttonText: 'Go' }, '');
|
||||
expect(html).toContain('Subscribe');
|
||||
expect(html).toContain('placeholder="you@example.com"');
|
||||
expect(html).toContain('>Go<');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,178 @@
|
||||
import React, { CSSProperties } from 'react';
|
||||
import { useNode, UserComponent } from '@craftjs/core';
|
||||
import { cssPropsToString } from '../../utils/style-helpers';
|
||||
import { escapeHtml, escapeAttr } from '../../utils/escape';
|
||||
|
||||
interface SubscribeFormProps {
|
||||
heading?: string;
|
||||
placeholder?: string;
|
||||
buttonText?: string;
|
||||
buttonColor?: string;
|
||||
layout?: 'inline' | 'stacked';
|
||||
style?: CSSProperties;
|
||||
}
|
||||
|
||||
export const SubscribeForm: UserComponent<SubscribeFormProps> = ({
|
||||
heading = 'Subscribe to our newsletter',
|
||||
placeholder = 'Enter your email',
|
||||
buttonText = 'Subscribe',
|
||||
buttonColor = '#3b82f6',
|
||||
layout = 'inline',
|
||||
style = {},
|
||||
}) => {
|
||||
const {
|
||||
connectors: { connect, drag },
|
||||
selected,
|
||||
} = useNode((node) => ({
|
||||
selected: node.events.selected,
|
||||
}));
|
||||
|
||||
const isInline = layout === 'inline';
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={(ref: HTMLDivElement | null): void => { if (ref) connect(drag(ref)); }}
|
||||
style={{
|
||||
padding: '40px 24px',
|
||||
textAlign: 'center',
|
||||
outline: selected ? '2px solid #3b82f6' : 'none',
|
||||
...style,
|
||||
}}
|
||||
>
|
||||
{heading && (
|
||||
<h3 style={{
|
||||
fontSize: '22px',
|
||||
fontWeight: '600',
|
||||
color: '#1f2937',
|
||||
marginBottom: '20px',
|
||||
fontFamily: 'Inter, sans-serif',
|
||||
}}>
|
||||
{heading}
|
||||
</h3>
|
||||
)}
|
||||
<form
|
||||
onSubmit={(e) => e.preventDefault()}
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: isInline ? 'row' : 'column',
|
||||
gap: isInline ? '0' : '12px',
|
||||
maxWidth: isInline ? '480px' : '360px',
|
||||
margin: '0 auto',
|
||||
alignItems: 'stretch',
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="email"
|
||||
placeholder={placeholder}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '12px 16px',
|
||||
fontSize: '15px',
|
||||
fontFamily: 'Inter, sans-serif',
|
||||
border: '1px solid #d1d5db',
|
||||
borderRadius: isInline ? '8px 0 0 8px' : '8px',
|
||||
backgroundColor: '#ffffff',
|
||||
color: '#1f2937',
|
||||
outline: 'none',
|
||||
boxSizing: 'border-box',
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
style={{
|
||||
padding: '12px 24px',
|
||||
fontSize: '15px',
|
||||
fontWeight: '600',
|
||||
fontFamily: 'Inter, sans-serif',
|
||||
color: '#ffffff',
|
||||
backgroundColor: buttonColor,
|
||||
border: 'none',
|
||||
borderRadius: isInline ? '0 8px 8px 0' : '8px',
|
||||
cursor: 'pointer',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{buttonText}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/* ---------- Craft config ---------- */
|
||||
|
||||
SubscribeForm.craft = {
|
||||
displayName: 'Subscribe Form',
|
||||
props: {
|
||||
heading: 'Subscribe to our newsletter',
|
||||
placeholder: 'Enter your email',
|
||||
buttonText: 'Subscribe',
|
||||
buttonColor: '#3b82f6',
|
||||
layout: 'inline',
|
||||
style: { backgroundColor: '#f8fafc' },
|
||||
},
|
||||
rules: {
|
||||
canDrag: () => true,
|
||||
canMoveIn: () => false,
|
||||
canMoveOut: () => true,
|
||||
},
|
||||
};
|
||||
|
||||
/* ---------- HTML export ---------- */
|
||||
|
||||
(SubscribeForm as any).toHtml = (props: SubscribeFormProps, _childrenHtml: string) => {
|
||||
const {
|
||||
heading = 'Subscribe to our newsletter',
|
||||
placeholder = 'Enter your email',
|
||||
buttonText = 'Subscribe',
|
||||
buttonColor = '#3b82f6',
|
||||
layout = 'inline',
|
||||
style = {},
|
||||
} = props;
|
||||
|
||||
const isInline = layout === 'inline';
|
||||
|
||||
const wrapperStyle = cssPropsToString({
|
||||
padding: '40px 24px',
|
||||
textAlign: 'center',
|
||||
...style,
|
||||
});
|
||||
|
||||
const headingHtml = heading
|
||||
? `<h3 style="font-size:22px;font-weight:600;color:#1f2937;margin-bottom:20px;font-family:Inter,sans-serif">${escapeHtml(heading)}</h3>`
|
||||
: '';
|
||||
|
||||
const formStyle = cssPropsToString({
|
||||
display: 'flex',
|
||||
flexDirection: isInline ? 'row' : 'column',
|
||||
gap: isInline ? '0' : '12px',
|
||||
maxWidth: isInline ? '480px' : '360px',
|
||||
margin: '0 auto',
|
||||
alignItems: 'stretch',
|
||||
});
|
||||
|
||||
const inputStyleStr = `flex:1;padding:12px 16px;font-size:15px;font-family:Inter,sans-serif;border:1px solid #d1d5db;border-radius:${isInline ? '8px 0 0 8px' : '8px'};background-color:#ffffff;color:#1f2937;outline:none;box-sizing:border-box`;
|
||||
|
||||
const btnStyle = cssPropsToString({
|
||||
padding: '12px 24px',
|
||||
fontSize: '15px',
|
||||
fontWeight: '600',
|
||||
fontFamily: 'Inter, sans-serif',
|
||||
color: '#ffffff',
|
||||
backgroundColor: buttonColor,
|
||||
border: 'none',
|
||||
borderRadius: isInline ? '0 8px 8px 0' : '8px',
|
||||
cursor: 'pointer',
|
||||
whiteSpace: 'nowrap',
|
||||
});
|
||||
|
||||
return {
|
||||
html: `<div${wrapperStyle ? ` style="${wrapperStyle}"` : ''}>
|
||||
${headingHtml}
|
||||
<form method="POST"${formStyle ? ` style="${formStyle}"` : ''}>
|
||||
<input type="email" name="email" placeholder="${escapeAttr(placeholder)}" required style="${inputStyleStr}" />
|
||||
<button type="submit"${btnStyle ? ` style="${btnStyle}"` : ''}>${escapeHtml(buttonText)}</button>
|
||||
</form>
|
||||
</div>`,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,65 @@
|
||||
import { describe, test, expect } from 'vitest';
|
||||
import { TextareaField } from './TextareaField';
|
||||
|
||||
const toHtml = (TextareaField as any).toHtml;
|
||||
|
||||
describe('TextareaField.toHtml accessibility (F2.1)', () => {
|
||||
test('label for= matches textarea id=', () => {
|
||||
const { html } = toHtml({ label: 'Message', name: 'message' }, '');
|
||||
const forMatch = html.match(/<label for="([^"]+)"/);
|
||||
const idMatch = html.match(/<textarea id="([^"]+)"/);
|
||||
expect(forMatch).toBeTruthy();
|
||||
expect(idMatch).toBeTruthy();
|
||||
expect(forMatch![1]).toBe(idMatch![1]);
|
||||
});
|
||||
|
||||
test('no visible label: textarea gets aria-label from placeholder', () => {
|
||||
const { html } = toHtml({ label: '', name: 'notes', placeholder: 'Anything else?' }, '');
|
||||
expect(html).not.toContain('<label');
|
||||
expect(html).toContain('aria-label="Anything else?"');
|
||||
});
|
||||
});
|
||||
|
||||
describe('TextareaField.toHtml deterministic + unique ids (thread node id, resolves id-collision finding)', () => {
|
||||
test('label for= still matches textarea id= after threading the node id', () => {
|
||||
const { html } = toHtml({ label: 'Message', name: 'message' }, '', 'node-ta1');
|
||||
const forMatch = html.match(/<label for="([^"]+)"/);
|
||||
const idMatch = html.match(/<textarea id="([^"]+)"/);
|
||||
expect(forMatch![1]).toBe(idMatch![1]);
|
||||
});
|
||||
|
||||
test('same node id -> identical output across calls (deterministic, no Math.random)', () => {
|
||||
const { html: html1 } = toHtml({ label: 'Message', name: 'message' }, '', 'node-ta1');
|
||||
const { html: html2 } = toHtml({ label: 'Message', name: 'message' }, '', 'node-ta1');
|
||||
expect(html1).toBe(html2);
|
||||
});
|
||||
|
||||
test('two instances with the SAME default name but different node ids do not collide', () => {
|
||||
const { html: html1 } = toHtml({ label: 'Message', name: 'message' }, '', 'node-ta1');
|
||||
const { html: html2 } = toHtml({ label: 'Message', name: 'message' }, '', 'node-ta2');
|
||||
const id1 = html1.match(/<textarea id="([^"]+)"/)![1];
|
||||
const id2 = html2.match(/<textarea id="([^"]+)"/)![1];
|
||||
expect(id1).not.toBe(id2);
|
||||
});
|
||||
|
||||
test('no nodeId (legacy 2-arg call): id derivation stays deterministic, not random', () => {
|
||||
const { html: html1 } = toHtml({ label: 'Message', name: 'message' }, '');
|
||||
const { html: html2 } = toHtml({ label: 'Message', name: 'message' }, '');
|
||||
const id1 = html1.match(/<textarea id="([^"]+)"/)![1];
|
||||
const id2 = html2.match(/<textarea id="([^"]+)"/)![1];
|
||||
expect(id1).toBe(id2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('TextareaField.toHtml rows attribute sanitization', () => {
|
||||
test('malicious rows value cannot break out of the attribute; falls back to a numeric rows', () => {
|
||||
const { html } = toHtml({ label: 'Message', name: 'message', rows: '4"><script>alert(1)</script>' as any }, '');
|
||||
expect(html).not.toContain('<script');
|
||||
expect(html).toMatch(/rows="\d+"/);
|
||||
});
|
||||
|
||||
test('legitimate numeric rows still passes through unchanged', () => {
|
||||
const { html } = toHtml({ label: 'Message', name: 'message', rows: 8 }, '');
|
||||
expect(html).toContain('rows="8"');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,124 @@
|
||||
import React, { CSSProperties } from 'react';
|
||||
import { useNode, UserComponent } from '@craftjs/core';
|
||||
import { cssPropsToString } from '../../utils/style-helpers';
|
||||
import { escapeHtml, escapeAttr, scopeId } from '../../utils/escape';
|
||||
|
||||
interface TextareaFieldProps {
|
||||
label?: string;
|
||||
name?: string;
|
||||
placeholder?: string;
|
||||
rows?: number;
|
||||
required?: boolean;
|
||||
style?: CSSProperties;
|
||||
}
|
||||
|
||||
export const TextareaField: UserComponent<TextareaFieldProps> = ({
|
||||
label = 'Message',
|
||||
name = 'message',
|
||||
placeholder = '',
|
||||
rows = 4,
|
||||
required = false,
|
||||
style = {},
|
||||
}) => {
|
||||
const {
|
||||
connectors: { connect, drag },
|
||||
selected,
|
||||
} = useNode((node) => ({
|
||||
selected: node.events.selected,
|
||||
}));
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={(ref: HTMLElement | null): void => { if (ref) connect(drag(ref)); }}
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '4px',
|
||||
outline: selected ? '2px solid #3b82f6' : 'none',
|
||||
borderRadius: '4px',
|
||||
...style,
|
||||
}}
|
||||
>
|
||||
{label && (
|
||||
<label style={{ fontSize: '14px', fontWeight: '500', color: '#18181b' }}>
|
||||
{label}{required && <span style={{ color: '#ef4444' }}> *</span>}
|
||||
</label>
|
||||
)}
|
||||
<textarea
|
||||
name={name}
|
||||
placeholder={placeholder}
|
||||
rows={rows}
|
||||
required={required}
|
||||
readOnly
|
||||
style={{
|
||||
padding: '10px 12px',
|
||||
border: '1px solid #d4d4d8',
|
||||
borderRadius: '6px',
|
||||
fontSize: '14px',
|
||||
color: '#18181b',
|
||||
backgroundColor: '#ffffff',
|
||||
outline: 'none',
|
||||
width: '100%',
|
||||
boxSizing: 'border-box',
|
||||
resize: 'vertical',
|
||||
fontFamily: 'inherit',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/* ---------- Craft config ---------- */
|
||||
|
||||
TextareaField.craft = {
|
||||
displayName: 'Textarea',
|
||||
props: {
|
||||
label: 'Message',
|
||||
name: 'message',
|
||||
placeholder: 'Enter your message',
|
||||
rows: 4,
|
||||
required: false,
|
||||
style: {},
|
||||
},
|
||||
rules: {
|
||||
canDrag: () => true,
|
||||
canMoveIn: () => false,
|
||||
canMoveOut: () => true,
|
||||
},
|
||||
};
|
||||
|
||||
/* ---------- HTML export ---------- */
|
||||
|
||||
(TextareaField as any).toHtml = (props: TextareaFieldProps, _childrenHtml: string, nodeId?: string) => {
|
||||
const wrapStyle = cssPropsToString({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '4px',
|
||||
...props.style,
|
||||
});
|
||||
const reqAttr = props.required ? ' required' : '';
|
||||
// `rows` is declared as a TS `number` but arrives unchecked (AI update_props
|
||||
// path only validates node_id; deserialized saved-state JSON is untyped at
|
||||
// runtime), so a string like `4"><script>...` must be coerced to a real
|
||||
// number before interpolation, not trusted as already-numeric.
|
||||
const rows = Number(props.rows) || 4;
|
||||
// Deterministic AND unique id: scoped on the Craft node id so the
|
||||
// <label for> always matches the <textarea id> AND two TextareaField
|
||||
// instances that share the same (often default) `name` -- e.g. two
|
||||
// untouched "Textarea" blocks both named "message" -- don't collide on
|
||||
// `field-message`. Falls back to the old name-derived hash for legacy
|
||||
// 2-arg call sites without a node id.
|
||||
const fieldId = scopeId(nodeId, props.name || 'message', 'field');
|
||||
const labelHtml = props.label
|
||||
? `<label for="${escapeAttr(fieldId)}" style="font-size:14px;font-weight:500;color:#18181b">${escapeHtml(props.label)}${props.required ? '<span style="color:#ef4444"> *</span>' : ''}</label>`
|
||||
: '';
|
||||
const ariaLabelAttr = !props.label
|
||||
? ` aria-label="${escapeAttr(props.placeholder || props.name || 'Textarea field')}"`
|
||||
: '';
|
||||
return {
|
||||
html: `<div${wrapStyle ? ` style="${wrapStyle}"` : ''}>
|
||||
${labelHtml}
|
||||
<textarea id="${escapeAttr(fieldId)}" name="${escapeAttr(props.name || 'message')}" placeholder="${escapeAttr(props.placeholder || '')}" rows="${escapeAttr(String(rows))}"${reqAttr}${ariaLabelAttr} style="padding:10px 12px;border:1px solid #d4d4d8;border-radius:6px;font-size:14px;color:#18181b;background-color:#ffffff;width:100%;box-sizing:border-box;resize:vertical;font-family:inherit"></textarea>
|
||||
</div>`,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,58 @@
|
||||
import { describe, test, expect } from 'vitest';
|
||||
import { BackgroundSection } from './BackgroundSection';
|
||||
|
||||
const toHtml = (BackgroundSection as any).toHtml;
|
||||
|
||||
describe('BackgroundSection.toHtml anchorId', () => {
|
||||
test('escapes a malicious anchorId (attribute breakout attempt)', () => {
|
||||
const { html } = toHtml({ anchorId: 'x" onmouseover="alert(1)' }, 'child');
|
||||
expect(html).not.toContain('onmouseover="alert(1)"');
|
||||
});
|
||||
|
||||
test('a normal anchorId still renders correctly', () => {
|
||||
const { html } = toHtml({ anchorId: 'my-bg-section' }, 'child');
|
||||
expect(html).toContain('id="my-bg-section"');
|
||||
});
|
||||
});
|
||||
|
||||
describe('BackgroundSection.toHtml style-value XSS hardening', () => {
|
||||
test('a malicious bgImage cannot break out of the outer style attribute via url(...)', () => {
|
||||
const malicious = 'javascript:alert(1)) foo{background:red}</style><script>alert(1)</script';
|
||||
const { html } = toHtml({ bgImage: malicious }, 'child');
|
||||
expect(html).not.toContain('<script>alert(1)</script>');
|
||||
expect(html).not.toContain('javascript:alert(1)');
|
||||
});
|
||||
|
||||
test('a malicious bgColor cannot break out of the outer style attribute', () => {
|
||||
const malicious = 'red" onmouseover="alert(1)';
|
||||
const { html } = toHtml({ bgColor: malicious }, 'child');
|
||||
expect(html).not.toContain('onmouseover="alert(1)"');
|
||||
});
|
||||
|
||||
test('a malicious overlayColor cannot break out of the overlay style attribute', () => {
|
||||
const malicious = 'red" onmouseover="alert(1)';
|
||||
const { html } = toHtml({ overlayColor: malicious }, 'child');
|
||||
expect(html).not.toContain('onmouseover="alert(1)"');
|
||||
});
|
||||
|
||||
test('a wrong-typed overlayOpacity (string, not number) cannot break out of the overlay style attribute', () => {
|
||||
const malicious = '0.4" onmouseover="alert(1)' as any;
|
||||
const { html } = toHtml({ overlayOpacity: malicious }, 'child');
|
||||
expect(html).not.toContain('onmouseover="alert(1)"');
|
||||
});
|
||||
|
||||
test('a malicious innerMaxWidth cannot break out of the inner style attribute', () => {
|
||||
const malicious = '1200px" onmouseover="alert(1)';
|
||||
const { html } = toHtml({ innerMaxWidth: malicious }, 'child');
|
||||
expect(html).not.toContain('onmouseover="alert(1)"');
|
||||
});
|
||||
|
||||
test('normal props still render correctly', () => {
|
||||
const { html } = toHtml({ bgImage: 'https://example.com/bg.jpg', bgColor: '#1e293b', overlayColor: '#000000', overlayOpacity: 0.4, innerMaxWidth: '1200px' }, 'child');
|
||||
expect(html).toContain("url('https://example.com/bg.jpg')");
|
||||
expect(html).toContain('background-color:#1e293b');
|
||||
expect(html).toContain('opacity:0.4');
|
||||
expect(html).toContain('max-width:1200px');
|
||||
expect(html).toContain('child');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,123 @@
|
||||
import React, { CSSProperties } from 'react';
|
||||
import { useNode, Element, UserComponent } from '@craftjs/core';
|
||||
import { Container } from './Container';
|
||||
import { cssPropsToString } from '../../utils/style-helpers';
|
||||
import { escapeAttr } from '../../utils/escape';
|
||||
|
||||
interface BackgroundSectionProps {
|
||||
bgImage?: string;
|
||||
bgColor?: string;
|
||||
overlayColor?: string;
|
||||
overlayOpacity?: number;
|
||||
innerMaxWidth?: string;
|
||||
style?: CSSProperties;
|
||||
children?: React.ReactNode;
|
||||
anchorId?: string;
|
||||
}
|
||||
|
||||
export const BackgroundSection: UserComponent<BackgroundSectionProps> = ({
|
||||
bgImage = '',
|
||||
bgColor = '#1e293b',
|
||||
overlayColor = '#000000',
|
||||
overlayOpacity = 0.4,
|
||||
innerMaxWidth = '1200px',
|
||||
style = {},
|
||||
anchorId,
|
||||
}) => {
|
||||
const { connectors: { connect, drag } } = useNode();
|
||||
|
||||
return (
|
||||
<section
|
||||
ref={(ref: HTMLElement | null): void => { if (ref) connect(drag(ref)); }}
|
||||
id={anchorId || undefined}
|
||||
style={{
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
minHeight: '200px',
|
||||
backgroundColor: bgColor,
|
||||
backgroundImage: bgImage ? `url(${bgImage})` : undefined,
|
||||
backgroundSize: 'cover',
|
||||
backgroundPosition: 'center',
|
||||
...style,
|
||||
}}
|
||||
>
|
||||
{/* Overlay */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
backgroundColor: overlayColor,
|
||||
opacity: overlayOpacity,
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
/>
|
||||
{/* Content */}
|
||||
<Element
|
||||
id="bg-section-inner"
|
||||
is={Container}
|
||||
canvas
|
||||
style={{
|
||||
position: 'relative',
|
||||
zIndex: 1,
|
||||
maxWidth: innerMaxWidth,
|
||||
margin: '0 auto',
|
||||
padding: '60px 20px',
|
||||
}}
|
||||
tag="div"
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
/* ---------- Craft config ---------- */
|
||||
|
||||
BackgroundSection.craft = {
|
||||
displayName: 'Background Section',
|
||||
props: {
|
||||
bgImage: '',
|
||||
bgColor: '#1e293b',
|
||||
overlayColor: '#000000',
|
||||
overlayOpacity: 0.4,
|
||||
innerMaxWidth: '1200px',
|
||||
style: { padding: '0' },
|
||||
anchorId: '',
|
||||
},
|
||||
rules: {
|
||||
canDrag: () => true,
|
||||
canMoveIn: () => false,
|
||||
canMoveOut: () => true,
|
||||
},
|
||||
};
|
||||
|
||||
/* ---------- HTML export ---------- */
|
||||
|
||||
(BackgroundSection as any).toHtml = (props: BackgroundSectionProps, childrenHtml: string) => {
|
||||
const outerStyle = cssPropsToString({
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
minHeight: '200px',
|
||||
backgroundColor: props.bgColor || '#1e293b',
|
||||
backgroundImage: props.bgImage ? `url(${props.bgImage})` : undefined,
|
||||
backgroundSize: 'cover',
|
||||
backgroundPosition: 'center',
|
||||
...props.style,
|
||||
});
|
||||
const overlayStyle = cssPropsToString({
|
||||
position: 'absolute',
|
||||
inset: '0',
|
||||
backgroundColor: props.overlayColor || '#000000',
|
||||
opacity: String(props.overlayOpacity ?? 0.4),
|
||||
pointerEvents: 'none',
|
||||
});
|
||||
const innerStyle = cssPropsToString({
|
||||
position: 'relative',
|
||||
zIndex: '1',
|
||||
maxWidth: props.innerMaxWidth || '1200px',
|
||||
margin: '0 auto',
|
||||
padding: '60px 20px',
|
||||
});
|
||||
const idAttr = props.anchorId ? ` id="${escapeAttr(props.anchorId)}"` : '';
|
||||
return {
|
||||
html: `<section${idAttr}${outerStyle ? ` style="${outerStyle}"` : ''}><div${overlayStyle ? ` style="${overlayStyle}"` : ''}></div><div${innerStyle ? ` style="${innerStyle}"` : ''}>${childrenHtml}</div></section>`,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,82 @@
|
||||
import { describe, test, expect } from 'vitest';
|
||||
import { ColumnLayout } from './ColumnLayout';
|
||||
|
||||
const toHtml = (ColumnLayout as any).toHtml;
|
||||
|
||||
describe('ColumnLayout.toHtml width export from split', () => {
|
||||
test('non-default split (70-30) exports per-column width CSS matching each column', () => {
|
||||
const { html } = toHtml({ columns: 2, split: '70-30', gap: '16px' }, '<div>A</div><div>B</div>');
|
||||
// First column gets 70%, second gets 30% (same mapping as getWidths()).
|
||||
expect(html).toMatch(/nth-child\(1\)[^}]*calc\(70% - 16px\)/);
|
||||
expect(html).toMatch(/nth-child\(2\)[^}]*calc\(30% - 16px\)/);
|
||||
});
|
||||
|
||||
test('default 50-50 split still exports equal widths', () => {
|
||||
const { html } = toHtml({ columns: 2, split: '50-50', gap: '16px' }, '<div>A</div><div>B</div>');
|
||||
expect(html).toMatch(/nth-child\(1\)[^}]*calc\(50% - 16px\)/);
|
||||
expect(html).toMatch(/nth-child\(2\)[^}]*calc\(50% - 16px\)/);
|
||||
});
|
||||
|
||||
test('3-column 33-33-33 split exports three width rules', () => {
|
||||
const { html } = toHtml({ columns: 3, split: '33-33-33', gap: '16px' }, '<div>A</div><div>B</div><div>C</div>');
|
||||
expect(html).toMatch(/nth-child\(1\)[^}]*calc\(33\.333% - 16px\)/);
|
||||
expect(html).toMatch(/nth-child\(2\)[^}]*calc\(33\.333% - 16px\)/);
|
||||
expect(html).toMatch(/nth-child\(3\)[^}]*calc\(33\.333% - 16px\)/);
|
||||
});
|
||||
|
||||
test('childrenHtml is preserved in the output', () => {
|
||||
const { html } = toHtml({ columns: 2, split: '70-30', gap: '16px' }, '<div>A</div><div>B</div>');
|
||||
expect(html).toContain('<div>A</div><div>B</div>');
|
||||
});
|
||||
});
|
||||
|
||||
describe('ColumnLayout.toHtml deterministic + unique scope ids (thread node id, no Math.random)', () => {
|
||||
const props = { columns: 2, split: '50-50', gap: '16px' };
|
||||
|
||||
test('same node id -> identical output across calls (deterministic)', () => {
|
||||
const { html: html1 } = toHtml(props, '', 'node-col1');
|
||||
const { html: html2 } = toHtml(props, '', 'node-col1');
|
||||
expect(html1).toBe(html2);
|
||||
});
|
||||
|
||||
test('different node ids -> different, non-colliding scope classes (identical columns/split/gap, no collision)', () => {
|
||||
const { html: html1 } = toHtml(props, '', 'node-col1');
|
||||
const { html: html2 } = toHtml(props, '', 'node-col2');
|
||||
const cls1 = html1.match(/class="([^"]+)"/)![1];
|
||||
const cls2 = html2.match(/class="([^"]+)"/)![1];
|
||||
expect(cls1).not.toBe(cls2);
|
||||
});
|
||||
|
||||
test('the <style> nth-child rule and the div class= use the SAME scope', () => {
|
||||
const { html } = toHtml(props, '', 'node-col1');
|
||||
const cls = html.match(/class="([^"]+)"/)![1];
|
||||
expect(html).toContain(`.${cls} > :nth-child(1)`);
|
||||
});
|
||||
|
||||
test('no nodeId (legacy 2-arg call): still deterministic across repeated calls, not random', () => {
|
||||
const { html: html1 } = toHtml(props, '<div>A</div><div>B</div>');
|
||||
const { html: html2 } = toHtml(props, '<div>A</div><div>B</div>');
|
||||
expect(html1).toBe(html2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ColumnLayout.toHtml XSS hardening (gap into <style>)', () => {
|
||||
test('a gap value containing </style><script> is neutralized in the <style>-context nth-child rule', () => {
|
||||
const malicious = '0px)}</style><script>alert(1)</script><style>{';
|
||||
const { html } = toHtml({ columns: 2, split: '50-50', gap: malicious }, '<div>A</div><div>B</div>', 'node-xss');
|
||||
expect(html).not.toContain('</style><script');
|
||||
expect(html).not.toContain('<script>alert(1)</script>');
|
||||
});
|
||||
|
||||
test('a gap value containing a quote/semicolon breakout is neutralized in the style attribute', () => {
|
||||
const malicious = '16px" onmouseover="alert(1)';
|
||||
const { html } = toHtml({ columns: 2, split: '50-50', gap: malicious }, '', 'node-xss2');
|
||||
expect(html).not.toMatch(/style="[^"]*"[^>]*onmouseover/);
|
||||
});
|
||||
|
||||
test('a normal gap value still renders correctly', () => {
|
||||
const { html } = toHtml({ columns: 2, split: '50-50', gap: '24px' }, '<div>A</div>', 'node-normal');
|
||||
expect(html).toContain('gap:24px');
|
||||
expect(html).toMatch(/calc\(50% - 24px\)/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,153 @@
|
||||
import React, { CSSProperties } from 'react';
|
||||
import { useNode, Element, UserComponent } from '@craftjs/core';
|
||||
import { Container } from './Container';
|
||||
import { cssPropsToString } from '../../utils/style-helpers';
|
||||
import { escapeAttr, scopeId, cssValue } from '../../utils/escape';
|
||||
|
||||
type SplitOption =
|
||||
| '100'
|
||||
| '50-50' | '30-70' | '70-30' | '40-60' | '60-40'
|
||||
| '33-33-33' | '25-50-25'
|
||||
| '25-25-25-25'
|
||||
| '20-20-20-20-20'
|
||||
| '16-16-16-16-16-16'
|
||||
| 'equal';
|
||||
|
||||
interface ColumnLayoutProps {
|
||||
columns?: number;
|
||||
split?: SplitOption;
|
||||
gap?: string;
|
||||
style?: CSSProperties;
|
||||
children?: React.ReactNode;
|
||||
anchorId?: string;
|
||||
}
|
||||
|
||||
const splitToWidths: Record<string, string[]> = {
|
||||
'100': ['100%'],
|
||||
'50-50': ['50%', '50%'],
|
||||
'30-70': ['30%', '70%'],
|
||||
'70-30': ['70%', '30%'],
|
||||
'40-60': ['40%', '60%'],
|
||||
'60-40': ['60%', '40%'],
|
||||
'33-33-33': ['33.333%', '33.333%', '33.333%'],
|
||||
'25-50-25': ['25%', '50%', '25%'],
|
||||
'25-25-25-25': ['25%', '25%', '25%', '25%'],
|
||||
'20-20-20-20-20': ['20%', '20%', '20%', '20%', '20%'],
|
||||
'16-16-16-16-16-16': ['16.666%', '16.666%', '16.666%', '16.666%', '16.666%', '16.666%'],
|
||||
};
|
||||
|
||||
function getWidths(split: SplitOption, columns: number): string[] {
|
||||
// Check predefined splits first
|
||||
if (split !== 'equal') {
|
||||
const defined = splitToWidths[split];
|
||||
if (defined && defined.length === columns) return defined;
|
||||
}
|
||||
|
||||
// Try parsing custom split string (e.g., "35-65" or "25-50-25")
|
||||
if (split && split !== 'equal' && split.includes('-')) {
|
||||
const parts = split.split('-').map(Number);
|
||||
if (parts.length === columns && parts.every(n => !isNaN(n) && n > 0)) {
|
||||
return parts.map(n => `${n}%`);
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: equal widths
|
||||
const w = `${(100 / columns).toFixed(3)}%`;
|
||||
return Array.from({ length: columns }, () => w);
|
||||
}
|
||||
|
||||
export const ColumnLayout: UserComponent<ColumnLayoutProps> = ({
|
||||
columns = 2,
|
||||
split = '50-50',
|
||||
gap = '16px',
|
||||
style = {},
|
||||
anchorId,
|
||||
}) => {
|
||||
const { connectors: { connect, drag } } = useNode();
|
||||
const widths = getWidths(split, columns);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={(ref: HTMLElement | null): void => { if (ref) connect(drag(ref)); }}
|
||||
id={anchorId || undefined}
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
gap,
|
||||
width: '100%',
|
||||
minHeight: '60px',
|
||||
...style,
|
||||
}}
|
||||
>
|
||||
{widths.map((w, i) => (
|
||||
<Element
|
||||
key={`col-${i}`}
|
||||
id={`col-${i}`}
|
||||
is={Container}
|
||||
canvas
|
||||
custom={{ className: 'craft-column' }}
|
||||
style={{ flex: `0 0 calc(${w} - ${gap})`, minHeight: '60px', padding: '8px' }}
|
||||
tag="div"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/* ---------- Craft config ---------- */
|
||||
|
||||
ColumnLayout.craft = {
|
||||
displayName: 'Columns',
|
||||
props: {
|
||||
columns: 2,
|
||||
split: '50-50',
|
||||
gap: '16px',
|
||||
style: {},
|
||||
anchorId: '',
|
||||
},
|
||||
rules: {
|
||||
canDrag: () => true,
|
||||
canMoveIn: () => false,
|
||||
canMoveOut: () => true,
|
||||
},
|
||||
};
|
||||
|
||||
/* ---------- HTML export ---------- */
|
||||
|
||||
(ColumnLayout as any).toHtml = (props: ColumnLayoutProps, childrenHtml: string, nodeId?: string) => {
|
||||
const columns = props.columns || 2;
|
||||
const split = props.split || '50-50';
|
||||
// Sanitized once here so BOTH the raw <style> nth-child rule below AND the
|
||||
// cssPropsToString-built outerStyle get a safe value -- gap is a raw
|
||||
// string-interpolation sink into a <style> block (worst case: </style>
|
||||
// breakout -> arbitrary <script>), see task-cssxss-brief.md.
|
||||
const gap = cssValue(props.gap) || '16px';
|
||||
const widths = getWidths(split, columns);
|
||||
|
||||
const outerStyle = cssPropsToString({
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
gap,
|
||||
width: '100%',
|
||||
...props.style,
|
||||
});
|
||||
const idAttr = props.anchorId ? ` id="${escapeAttr(props.anchorId)}"` : '';
|
||||
|
||||
// Each column is exported as an independently-serialized child node, so
|
||||
// toHtml has no direct handle on individual children to rewrite their
|
||||
// inline flex-basis. Instead, scope an nth-child CSS rule (with
|
||||
// !important, to win over any stale inline flex baked into a child at
|
||||
// creation time) to a generated class -- same width mapping (getWidths)
|
||||
// the editor render uses. Precedent: Menu/Navbar toHtml already emit
|
||||
// scoped <style> blocks for hover CSS. The class is scoped on the Craft
|
||||
// node id so two ColumnLayout instances with identical columns/split/gap
|
||||
// don't collide on the same class and cross-apply each other's widths.
|
||||
const scope = scopeId(nodeId, `${columns}:${split}:${gap}`, 'cols');
|
||||
const widthCss = widths
|
||||
.map((w, i) => `.${scope} > :nth-child(${i + 1}) { flex: 0 0 calc(${w} - ${gap}) !important; }`)
|
||||
.join('\n ');
|
||||
|
||||
return {
|
||||
html: `<style>\n ${widthCss}\n</style>\n<div class="${scope}"${idAttr}${outerStyle ? ` style="${outerStyle}"` : ''}>${childrenHtml}</div>`,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,65 @@
|
||||
import { describe, test, expect } from 'vitest';
|
||||
import { Container } from './Container';
|
||||
|
||||
const toHtml = (Container as any).toHtml;
|
||||
|
||||
describe('Container.toHtml cssId/cssClass', () => {
|
||||
test('emits id and class when both set', () => {
|
||||
const { html } = toHtml({ cssId: 'my-id', cssClass: 'my-class' }, 'child');
|
||||
expect(html).toContain('id="my-id"');
|
||||
expect(html).toContain('class="my-class"');
|
||||
});
|
||||
|
||||
test('emits neither id nor class when empty/unset', () => {
|
||||
const { html } = toHtml({}, 'child');
|
||||
expect(html).not.toContain(' id="');
|
||||
expect(html).not.toContain(' class="');
|
||||
});
|
||||
|
||||
test('escapes cssId/cssClass values', () => {
|
||||
const { html } = toHtml({ cssId: 'x" onerror="alert(1)', cssClass: 'y" onerror="alert(1)' }, 'child');
|
||||
expect(html).not.toContain('onerror="alert(1)"');
|
||||
});
|
||||
|
||||
test('cssId takes precedence over anchorId when both set (no duplicate id attrs)', () => {
|
||||
const { html } = toHtml({ cssId: 'explicit-id', anchorId: 'anchor-id' }, 'child');
|
||||
const idMatches = html.match(/ id="/g) || [];
|
||||
expect(idMatches.length).toBe(1);
|
||||
expect(html).toContain('id="explicit-id"');
|
||||
});
|
||||
|
||||
test('falls back to anchorId when cssId is not set', () => {
|
||||
const { html } = toHtml({ anchorId: 'anchor-id' }, 'child');
|
||||
expect(html).toContain('id="anchor-id"');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Container.toHtml tag allowlist (adversarial re-review, same class as C1)', () => {
|
||||
test('a malicious tag value falls back to div -- no injected <img>, no broken-out attrs', () => {
|
||||
const { html } = toHtml({ tag: 'div><img src=x onerror=alert(1)' }, 'child');
|
||||
expect(html).not.toContain('<img');
|
||||
expect(html).not.toContain('onerror');
|
||||
expect(html.startsWith('<div')).toBe(true);
|
||||
expect(html.endsWith('</div>')).toBe(true);
|
||||
});
|
||||
|
||||
test('a tag value outside the known-safe set falls back to div', () => {
|
||||
const { html } = toHtml({ tag: 'script' }, 'child');
|
||||
expect(html.startsWith('<div')).toBe(true);
|
||||
expect(html).not.toContain('<script');
|
||||
});
|
||||
|
||||
test('a valid tag (section) still emits <section', () => {
|
||||
const { html } = toHtml({ tag: 'section' }, 'child');
|
||||
expect(html).toContain('<section');
|
||||
expect(html).toContain('</section>');
|
||||
});
|
||||
|
||||
test('all other allowlisted tags still work', () => {
|
||||
for (const tag of ['div', 'article', 'header', 'footer', 'main']) {
|
||||
const { html } = toHtml({ tag }, 'child');
|
||||
expect(html.startsWith(`<${tag}`)).toBe(true);
|
||||
expect(html.endsWith(`</${tag}>`)).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,140 @@
|
||||
import React, { CSSProperties } from 'react';
|
||||
import { useNode, UserComponent } from '@craftjs/core';
|
||||
import { cssPropsToString } from '../../utils/style-helpers';
|
||||
import { escapeAttr } from '../../utils/escape';
|
||||
|
||||
// The only tag names Container actually supports (matches the TS union
|
||||
// below and the `tag` default in `.craft.props`). `tag` is settable via the
|
||||
// AI `update_props` path and from deserialized saved state -- neither is
|
||||
// type-checked at runtime -- so a malicious value like
|
||||
// `div><img src=x onerror=alert(1)` must never reach the `<${tag}` template
|
||||
// position in `toHtml`/the live render. Anything not in this allowlist
|
||||
// falls back to `'div'`.
|
||||
const ALLOWED_CONTAINER_TAGS = ['div', 'section', 'article', 'header', 'footer', 'main'] as const;
|
||||
export type ContainerTag = (typeof ALLOWED_CONTAINER_TAGS)[number];
|
||||
|
||||
export const sanitizeContainerTag = (tag: unknown): ContainerTag =>
|
||||
(ALLOWED_CONTAINER_TAGS as readonly unknown[]).includes(tag) ? (tag as ContainerTag) : 'div';
|
||||
|
||||
interface ContainerProps {
|
||||
style?: CSSProperties;
|
||||
tag?: 'div' | 'section' | 'article' | 'header' | 'footer' | 'main';
|
||||
children?: React.ReactNode;
|
||||
cssId?: string;
|
||||
cssClass?: string;
|
||||
anchorId?: string;
|
||||
hideOnDesktop?: boolean;
|
||||
hideOnTablet?: boolean;
|
||||
hideOnMobile?: boolean;
|
||||
animation?: string;
|
||||
animationDelay?: string;
|
||||
fullWidth?: boolean;
|
||||
contentWidth?: 'boxed' | 'full';
|
||||
}
|
||||
|
||||
// Map textAlign to a flex alignItems value so block-level children (images,
|
||||
// columns, sections) align horizontally — textAlign alone only affects inline
|
||||
// content. Returns undefined when no alignment is set so we leave layout as
|
||||
// normal block flow.
|
||||
const flexAlignFromTextAlign = (textAlign: CSSProperties['textAlign']): CSSProperties => {
|
||||
if (textAlign === 'center') return { display: 'flex', flexDirection: 'column', alignItems: 'center' };
|
||||
if (textAlign === 'right') return { display: 'flex', flexDirection: 'column', alignItems: 'flex-end' };
|
||||
if (textAlign === 'left') return { display: 'flex', flexDirection: 'column', alignItems: 'flex-start' };
|
||||
return {};
|
||||
};
|
||||
|
||||
export const Container: UserComponent<ContainerProps> = ({
|
||||
style = {},
|
||||
tag = 'div',
|
||||
children,
|
||||
fullWidth = false,
|
||||
contentWidth = 'full',
|
||||
anchorId,
|
||||
cssId,
|
||||
cssClass,
|
||||
}) => {
|
||||
const { connectors: { connect, drag } } = useNode();
|
||||
|
||||
const safeTag = sanitizeContainerTag(tag);
|
||||
const needsBoxedWrapper = contentWidth === 'boxed';
|
||||
const flexStyles = flexAlignFromTextAlign(style.textAlign);
|
||||
|
||||
const outerStyle: CSSProperties = {
|
||||
minHeight: '40px',
|
||||
...style,
|
||||
...(fullWidth ? { width: '100vw', marginLeft: 'calc(-50vw + 50%)' } : {}),
|
||||
...(needsBoxedWrapper ? {} : flexStyles),
|
||||
};
|
||||
|
||||
// cssId is the user-facing "CSS ID" advanced field; it takes precedence
|
||||
// over anchorId (the scroll-jump anchor) when both happen to be set, since
|
||||
// only one `id` attribute can be emitted on the element.
|
||||
const idValue = cssId || anchorId || undefined;
|
||||
|
||||
const el = React.createElement(
|
||||
safeTag,
|
||||
{
|
||||
ref: (ref: HTMLElement | null): void => { if (ref) connect(drag(ref)); },
|
||||
style: outerStyle,
|
||||
'data-craft-container': 'true',
|
||||
id: idValue,
|
||||
className: cssClass || undefined,
|
||||
},
|
||||
needsBoxedWrapper
|
||||
? React.createElement('div', { style: { maxWidth: '1200px', margin: '0 auto', ...flexStyles } }, children)
|
||||
: children,
|
||||
);
|
||||
|
||||
return el;
|
||||
};
|
||||
|
||||
/* ---------- Craft config ---------- */
|
||||
|
||||
Container.craft = {
|
||||
displayName: 'Container',
|
||||
props: {
|
||||
style: { padding: '20px', minHeight: '100px' },
|
||||
tag: 'div',
|
||||
fullWidth: false,
|
||||
contentWidth: 'full',
|
||||
anchorId: '',
|
||||
cssId: '',
|
||||
cssClass: '',
|
||||
},
|
||||
rules: {
|
||||
canDrag: () => true,
|
||||
canMoveIn: () => true,
|
||||
canMoveOut: () => true,
|
||||
},
|
||||
};
|
||||
|
||||
/* ---------- HTML export ---------- */
|
||||
|
||||
(Container as any).toHtml = (props: ContainerProps, childrenHtml: string) => {
|
||||
const tag = sanitizeContainerTag(props.tag);
|
||||
const isBoxed = props.contentWidth === 'boxed';
|
||||
const flexStyles = flexAlignFromTextAlign(props.style?.textAlign);
|
||||
|
||||
const outerCss: CSSProperties = {
|
||||
...props.style,
|
||||
...(isBoxed ? {} : flexStyles),
|
||||
};
|
||||
|
||||
if (props.fullWidth) {
|
||||
outerCss.width = '100vw';
|
||||
outerCss.marginLeft = 'calc(-50vw + 50%)';
|
||||
}
|
||||
|
||||
const styleStr = cssPropsToString(outerCss);
|
||||
// cssId wins over anchorId when both are set (see the render fn above for why).
|
||||
const idValue = props.cssId || props.anchorId;
|
||||
const idAttr = idValue ? ` id="${escapeAttr(idValue)}"` : '';
|
||||
const classAttr = props.cssClass ? ` class="${escapeAttr(props.cssClass)}"` : '';
|
||||
|
||||
if (isBoxed) {
|
||||
const innerStyle = cssPropsToString({ maxWidth: '1200px', margin: '0 auto', ...flexStyles });
|
||||
return { html: `<${tag}${idAttr}${classAttr}${styleStr ? ` style="${styleStr}"` : ''}><div${innerStyle ? ` style="${innerStyle}"` : ''}>${childrenHtml}</div></${tag}>` };
|
||||
}
|
||||
|
||||
return { html: `<${tag}${idAttr}${classAttr}${styleStr ? ` style="${styleStr}"` : ''}>${childrenHtml}</${tag}>` };
|
||||
};
|
||||
@@ -0,0 +1,75 @@
|
||||
import { describe, test, expect } from 'vitest';
|
||||
import { Section } from './Section';
|
||||
|
||||
const toHtml = (Section as any).toHtml;
|
||||
|
||||
describe('Section.toHtml anchorId', () => {
|
||||
test('escapes a malicious anchorId (attribute breakout attempt)', () => {
|
||||
const { html } = toHtml({ anchorId: 'x" onmouseover="alert(1)' }, 'child');
|
||||
expect(html).not.toContain('onmouseover="alert(1)"');
|
||||
});
|
||||
|
||||
test('a normal anchorId still renders correctly', () => {
|
||||
const { html } = toHtml({ anchorId: 'my-section' }, 'child');
|
||||
expect(html).toContain('id="my-section"');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Section.toHtml childrenHtml passthrough', () => {
|
||||
test('children are preserved', () => {
|
||||
const { html } = toHtml({}, '<p>hello</p>');
|
||||
expect(html).toContain('<p>hello</p>');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Section.toHtml shape divider color/height XSS hardening', () => {
|
||||
test('a malicious topDividerColor cannot break out of the SVG style attribute', () => {
|
||||
const malicious = 'red" onmouseover="alert(1)';
|
||||
const { html } = toHtml({ topDivider: 'wave', topDividerColor: malicious }, '');
|
||||
expect(html).not.toContain('onmouseover="alert(1)"');
|
||||
});
|
||||
|
||||
test('a malicious topDividerColor cannot inject a </style><script> breakout', () => {
|
||||
const malicious = 'red</style><script>alert(1)</script>';
|
||||
const { html } = toHtml({ topDivider: 'wave', topDividerColor: malicious }, '');
|
||||
expect(html).not.toContain('<script>alert(1)</script>');
|
||||
});
|
||||
|
||||
test('a malicious bottomDividerHeight cannot break out of the wrapper style attribute', () => {
|
||||
const malicious = '50px" onmouseover="alert(1)';
|
||||
const { html } = toHtml({ bottomDivider: 'angle', bottomDividerHeight: malicious }, '');
|
||||
expect(html).not.toContain('onmouseover="alert(1)"');
|
||||
});
|
||||
|
||||
test('a normal divider color/height still renders correctly', () => {
|
||||
const { html } = toHtml({ topDivider: 'wave', topDividerColor: '#123456', topDividerHeight: '80px' }, '');
|
||||
expect(html).toContain('fill:#123456');
|
||||
expect(html).toContain('height:80px');
|
||||
});
|
||||
|
||||
test('divider shape "none" emits no divider markup', () => {
|
||||
const { html } = toHtml({ topDivider: 'none' }, 'child');
|
||||
expect(html).not.toContain('<svg');
|
||||
});
|
||||
|
||||
test('an unrecognized divider shape value emits no divider markup and no injected content', () => {
|
||||
const malicious = 'wave"><script>alert(1)</script>' as any;
|
||||
const { html } = toHtml({ topDivider: malicious }, 'child');
|
||||
expect(html).not.toContain('<script>alert(1)</script>');
|
||||
expect(html).not.toContain('<svg');
|
||||
});
|
||||
|
||||
test('a prototype-property-name divider shape (__proto__) does not leak [object Object]/function source into the SVG path', () => {
|
||||
const { html } = toHtml({ topDivider: '__proto__' as any }, 'child');
|
||||
expect(html).not.toContain('[object');
|
||||
expect(html).not.toContain('native code');
|
||||
expect(html).not.toContain('<svg');
|
||||
});
|
||||
|
||||
test('a prototype-property-name divider shape (toString) does not leak Object.prototype.toString source into the SVG path', () => {
|
||||
const { html } = toHtml({ topDivider: 'toString' as any }, 'child');
|
||||
expect(html).not.toContain('[object');
|
||||
expect(html).not.toContain('native code');
|
||||
expect(html).not.toContain('<svg');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,222 @@
|
||||
import React, { CSSProperties } from 'react';
|
||||
import { useNode, Element, UserComponent } from '@craftjs/core';
|
||||
import { cssPropsToString } from '../../utils/style-helpers';
|
||||
import { Container } from './Container';
|
||||
import { escapeAttr, cssValue } from '../../utils/escape';
|
||||
|
||||
/* ---------- Shape Divider SVG Paths ---------- */
|
||||
|
||||
type DividerShape = 'none' | 'wave' | 'angle' | 'curve' | 'triangle' | 'zigzag';
|
||||
|
||||
const DIVIDER_PATHS: Record<Exclude<DividerShape, 'none'>, string> = {
|
||||
wave: 'M0,0 C150,120 350,0 600,60 C850,120 1050,0 1200,60 L1200,120 L0,120 Z',
|
||||
angle: 'M0,0 L1200,120 L0,120 Z',
|
||||
curve: 'M0,0 Q600,140 1200,0 L1200,120 L0,120 Z',
|
||||
triangle: 'M0,120 L600,0 L1200,120 Z',
|
||||
zigzag: 'M0,120 L100,40 L200,120 L300,40 L400,120 L500,40 L600,120 L700,40 L800,120 L900,40 L1000,120 L1100,40 L1200,120 Z',
|
||||
};
|
||||
|
||||
interface SectionProps {
|
||||
style?: CSSProperties;
|
||||
innerMaxWidth?: string;
|
||||
children?: React.ReactNode;
|
||||
topDivider?: DividerShape;
|
||||
topDividerColor?: string;
|
||||
topDividerHeight?: string;
|
||||
bottomDivider?: DividerShape;
|
||||
bottomDividerColor?: string;
|
||||
bottomDividerHeight?: string;
|
||||
anchorId?: string;
|
||||
}
|
||||
|
||||
/* ---------- Divider renderer ---------- */
|
||||
|
||||
const ShapeDivider: React.FC<{
|
||||
shape: DividerShape;
|
||||
color: string;
|
||||
height: string;
|
||||
position: 'top' | 'bottom';
|
||||
}> = ({ shape, color, height, position }) => {
|
||||
if (!shape || shape === 'none') return null;
|
||||
// `shape` is attacker-controlled (AI update_props / deserialized state) and
|
||||
// not runtime-type-checked. A plain-object index lookup with a string key
|
||||
// like '__proto__', 'toString', or 'constructor' returns an INHERITED
|
||||
// Object.prototype value (not undefined), which would otherwise leak
|
||||
// "[object Object]" / a function's source text into the SVG `d` attribute
|
||||
// below. hasOwnProperty restricts the lookup to the real allowlisted keys.
|
||||
if (!Object.prototype.hasOwnProperty.call(DIVIDER_PATHS, shape)) return null;
|
||||
const path = DIVIDER_PATHS[shape as Exclude<DividerShape, 'none'>];
|
||||
if (!path) return null;
|
||||
|
||||
const isTop = position === 'top';
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
[position]: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: height || '50px',
|
||||
overflow: 'hidden',
|
||||
lineHeight: 0,
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 1200 120"
|
||||
preserveAspectRatio="none"
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
fill: color || '#ffffff',
|
||||
transform: isTop ? 'rotate(180deg)' : undefined,
|
||||
display: 'block',
|
||||
}}
|
||||
>
|
||||
<path d={path} />
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/* ---------- Component ---------- */
|
||||
|
||||
export const Section: UserComponent<SectionProps> = ({
|
||||
style = {},
|
||||
innerMaxWidth = '1200px',
|
||||
children,
|
||||
topDivider = 'none',
|
||||
topDividerColor = '#ffffff',
|
||||
topDividerHeight = '50px',
|
||||
bottomDivider = 'none',
|
||||
bottomDividerColor = '#ffffff',
|
||||
bottomDividerHeight = '50px',
|
||||
anchorId,
|
||||
}) => {
|
||||
const { connectors: { connect, drag } } = useNode();
|
||||
|
||||
const hasTopDivider = topDivider && topDivider !== 'none';
|
||||
const hasBottomDivider = bottomDivider && bottomDivider !== 'none';
|
||||
|
||||
return (
|
||||
<section
|
||||
ref={(ref: HTMLElement | null) => { if (ref) connect(drag(ref)); }}
|
||||
id={anchorId || undefined}
|
||||
style={{
|
||||
width: '100%',
|
||||
position: (hasTopDivider || hasBottomDivider) ? 'relative' : undefined,
|
||||
...style,
|
||||
}}
|
||||
>
|
||||
{hasTopDivider && (
|
||||
<ShapeDivider
|
||||
shape={topDivider}
|
||||
color={topDividerColor}
|
||||
height={topDividerHeight}
|
||||
position="top"
|
||||
/>
|
||||
)}
|
||||
<Element
|
||||
id="section-inner"
|
||||
is={Container}
|
||||
canvas
|
||||
style={{ maxWidth: innerMaxWidth, margin: '0 auto', position: 'relative', zIndex: 1 }}
|
||||
tag="div"
|
||||
>
|
||||
{children}
|
||||
</Element>
|
||||
{hasBottomDivider && (
|
||||
<ShapeDivider
|
||||
shape={bottomDivider}
|
||||
color={bottomDividerColor}
|
||||
height={bottomDividerHeight}
|
||||
position="bottom"
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
/* ---------- Craft config ---------- */
|
||||
|
||||
Section.craft = {
|
||||
displayName: 'Section',
|
||||
props: {
|
||||
style: { padding: '40px 0', backgroundColor: '#ffffff' },
|
||||
innerMaxWidth: '1200px',
|
||||
topDivider: 'none',
|
||||
topDividerColor: '#ffffff',
|
||||
topDividerHeight: '50px',
|
||||
bottomDivider: 'none',
|
||||
bottomDividerColor: '#ffffff',
|
||||
bottomDividerHeight: '50px',
|
||||
anchorId: '',
|
||||
},
|
||||
rules: {
|
||||
canDrag: () => true,
|
||||
canMoveIn: () => true,
|
||||
canMoveOut: () => true,
|
||||
},
|
||||
};
|
||||
|
||||
/* ---------- HTML export ---------- */
|
||||
|
||||
function buildDividerHtml(
|
||||
shape: DividerShape | undefined,
|
||||
color: string | undefined,
|
||||
height: string | undefined,
|
||||
position: 'top' | 'bottom',
|
||||
): string {
|
||||
if (!shape || shape === 'none') return '';
|
||||
// See the matching hasOwnProperty guard in <ShapeDivider> above -- same
|
||||
// prototype-pollution-shaped lookup, same fix.
|
||||
if (!Object.prototype.hasOwnProperty.call(DIVIDER_PATHS, shape)) return '';
|
||||
const path = DIVIDER_PATHS[shape as Exclude<DividerShape, 'none'>];
|
||||
if (!path) return '';
|
||||
|
||||
const isTop = position === 'top';
|
||||
const h = height || '50px';
|
||||
// Sanitized -- raw string-interpolation sink in the SVG `fill:${c}` below.
|
||||
const c = cssValue(color) || '#ffffff';
|
||||
|
||||
const wrapperStyle = cssPropsToString({
|
||||
position: 'absolute',
|
||||
[position]: '0',
|
||||
left: '0',
|
||||
right: '0',
|
||||
height: h,
|
||||
overflow: 'hidden',
|
||||
lineHeight: '0',
|
||||
pointerEvents: 'none',
|
||||
} as CSSProperties);
|
||||
|
||||
const svgTransform = isTop ? ' transform:rotate(180deg);' : '';
|
||||
|
||||
return `<div style="${wrapperStyle}"><svg viewBox="0 0 1200 120" preserveAspectRatio="none" style="width:100%;height:100%;fill:${c};display:block;${svgTransform}"><path d="${path}"/></svg></div>`;
|
||||
}
|
||||
|
||||
(Section as any).toHtml = (props: SectionProps, childrenHtml: string) => {
|
||||
const hasTopDivider = props.topDivider && props.topDivider !== 'none';
|
||||
const hasBottomDivider = props.bottomDivider && props.bottomDivider !== 'none';
|
||||
|
||||
const outerStyle = cssPropsToString({
|
||||
width: '100%',
|
||||
position: (hasTopDivider || hasBottomDivider) ? 'relative' : undefined,
|
||||
...props.style,
|
||||
});
|
||||
const innerStyle = cssPropsToString({
|
||||
maxWidth: props.innerMaxWidth || '1200px',
|
||||
margin: '0 auto',
|
||||
position: (hasTopDivider || hasBottomDivider) ? 'relative' : undefined,
|
||||
zIndex: (hasTopDivider || hasBottomDivider) ? 1 : undefined,
|
||||
} as CSSProperties);
|
||||
|
||||
const topHtml = buildDividerHtml(props.topDivider, props.topDividerColor, props.topDividerHeight, 'top');
|
||||
const bottomHtml = buildDividerHtml(props.bottomDivider, props.bottomDividerColor, props.bottomDividerHeight, 'bottom');
|
||||
const idAttr = props.anchorId ? ` id="${escapeAttr(props.anchorId)}"` : '';
|
||||
|
||||
return {
|
||||
html: `<section${idAttr}${outerStyle ? ` style="${outerStyle}"` : ''}>${topHtml}<div${innerStyle ? ` style="${innerStyle}"` : ''}>${childrenHtml}</div>${bottomHtml}</section>`,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,62 @@
|
||||
import { describe, test, expect, vi, beforeEach } from 'vitest';
|
||||
import React from 'react';
|
||||
import { createRoot, Root } from 'react-dom/client';
|
||||
import { act } from 'react-dom/test-utils';
|
||||
|
||||
/* ImageBlock only needs useNode from @craftjs/core. Mock it following the
|
||||
DOM-harness pattern in src/components/basic/Footer.editguard.test.tsx (no
|
||||
@testing-library/react in this repo) so we can render the real component
|
||||
tree and inspect the emitted <img src> without a real <Editor>. */
|
||||
vi.mock('@craftjs/core', () => ({
|
||||
useNode: (collect?: (node: any) => any) => {
|
||||
const node = { events: { selected: false } };
|
||||
return {
|
||||
connectors: { connect: (el: any) => el, drag: (el: any) => el },
|
||||
actions: { setProp: vi.fn() },
|
||||
...(collect ? collect(node) : {}),
|
||||
};
|
||||
},
|
||||
}));
|
||||
|
||||
import { ImageBlock } from './ImageBlock';
|
||||
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
|
||||
function render(ui: React.ReactElement) {
|
||||
container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
act(() => {
|
||||
root = createRoot(container);
|
||||
root.render(ui);
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('ImageBlock render falls back to the placeholder for an explicit empty src (Bug 1)', () => {
|
||||
test('src="" (explicit, overrides the default parameter) still renders a non-empty placeholder src', () => {
|
||||
render(<ImageBlock src="" alt="Image" />);
|
||||
const img = container.querySelector('img')!;
|
||||
expect(img.getAttribute('src')).not.toBe('');
|
||||
expect(img.getAttribute('src')).toMatch(/^data:image\/svg\+xml/);
|
||||
container.remove();
|
||||
});
|
||||
|
||||
test('src=undefined (default parameter path) still renders the placeholder (unchanged behavior)', () => {
|
||||
render(<ImageBlock alt="Image" />);
|
||||
const img = container.querySelector('img')!;
|
||||
expect(img.getAttribute('src')).not.toBe('');
|
||||
expect(img.getAttribute('src')).toMatch(/^data:image\/svg\+xml/);
|
||||
container.remove();
|
||||
});
|
||||
|
||||
test('a real src is rendered unchanged', () => {
|
||||
render(<ImageBlock src="https://example.com/photo.jpg" alt="A photo" />);
|
||||
const img = container.querySelector('img')!;
|
||||
expect(img.getAttribute('src')).toBe('https://example.com/photo.jpg');
|
||||
container.remove();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
import { describe, test, expect } from 'vitest';
|
||||
import { ImageBlock } from './ImageBlock';
|
||||
|
||||
const toHtml = (ImageBlock as any).toHtml;
|
||||
|
||||
describe('ImageBlock.toHtml src/alt XSS hardening', () => {
|
||||
test('a javascript: src never reaches the output', () => {
|
||||
const { html } = toHtml({ src: 'javascript:alert(1)' }, '');
|
||||
expect(html).not.toContain('javascript:');
|
||||
});
|
||||
|
||||
test('a malicious src cannot break out of the src attribute', () => {
|
||||
const malicious = 'https://example.com/x.jpg" onerror="alert(1)';
|
||||
const { html } = toHtml({ src: malicious }, '');
|
||||
expect(html).not.toContain('onerror="alert(1)"');
|
||||
});
|
||||
|
||||
test('a malicious alt cannot break out of the alt attribute', () => {
|
||||
const malicious = 'x" onerror="alert(1)';
|
||||
const { html } = toHtml({ src: 'https://example.com/x.jpg', alt: malicious }, '');
|
||||
expect(html).not.toContain('onerror="alert(1)"');
|
||||
});
|
||||
|
||||
test('a placeholder/empty src emits no output', () => {
|
||||
const { html } = toHtml({ src: '' }, '');
|
||||
expect(html).toBe('');
|
||||
});
|
||||
|
||||
test('a normal image still renders correctly', () => {
|
||||
const { html } = toHtml({ src: 'https://example.com/photo.jpg', alt: 'A photo' }, '');
|
||||
expect(html).toContain('src="https://example.com/photo.jpg"');
|
||||
expect(html).toContain('alt="A photo"');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,99 @@
|
||||
import React, { CSSProperties, useCallback, useRef } from 'react';
|
||||
import { useNode, UserComponent } from '@craftjs/core';
|
||||
import { cssPropsToString } from '../../utils/style-helpers';
|
||||
import { escapeAttr, safeImageUrl } from '../../utils/escape';
|
||||
|
||||
export const PLACEHOLDER_SRC = "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='400' height='300'%3E%3Cdefs%3E%3ClinearGradient id='bg' x1='0' y1='0' x2='0' y2='1'%3E%3Cstop offset='0%25' stop-color='%23f1f5f9'/%3E%3Cstop offset='100%25' stop-color='%23e2e8f0'/%3E%3C/linearGradient%3E%3C/defs%3E%3Crect fill='url(%23bg)' width='400' height='300' rx='12'/%3E%3Crect x='2' y='2' width='396' height='296' rx='10' fill='none' stroke='%23cbd5e1' stroke-width='2' stroke-dasharray='8 4'/%3E%3Cg transform='translate(200,110)'%3E%3Crect x='-28' y='-28' width='56' height='56' rx='12' fill='%23cbd5e1' opacity='0.5'/%3E%3Cpath d='M-12 8 L-4 -2 L2 4 L8 -6 L16 8Z' fill='%2394a3b8'/%3E%3Ccircle cx='-6' cy='-10' r='5' fill='%2394a3b8'/%3E%3C/g%3E%3Ctext x='200' y='160' text-anchor='middle' fill='%2364748b' font-family='Inter,sans-serif' font-size='15' font-weight='500'%3EDrop image here%3C/text%3E%3Ctext x='200' y='182' text-anchor='middle' fill='%2394a3b8' font-family='Inter,sans-serif' font-size='12'%3Eor click to upload%3C/text%3E%3C/svg%3E";
|
||||
|
||||
interface ImageBlockProps {
|
||||
src?: string;
|
||||
alt?: string;
|
||||
style?: CSSProperties;
|
||||
}
|
||||
|
||||
// Helper: upload a file to the WHP API and return the proxy URL
|
||||
async function uploadToWhp(file: File): Promise<string | null> {
|
||||
const cfg = (window as any).WHP_CONFIG;
|
||||
if (!cfg) return URL.createObjectURL(file); // Standalone fallback
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
try {
|
||||
const resp = await fetch(`${cfg.apiUrl}?action=upload_asset&site_id=${cfg.siteId}`, {
|
||||
method: 'POST',
|
||||
headers: { 'X-CSRF-Token': cfg.csrfToken },
|
||||
body: formData,
|
||||
});
|
||||
const data = await resp.json();
|
||||
if (data.success && data.url) return data.url;
|
||||
return null;
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
export const ImageBlock: UserComponent<ImageBlockProps> = ({
|
||||
src = PLACEHOLDER_SRC,
|
||||
alt = '',
|
||||
style = {},
|
||||
}) => {
|
||||
const {
|
||||
connectors: { connect, drag },
|
||||
selected,
|
||||
actions: { setProp },
|
||||
} = useNode((node) => ({ selected: node.events.selected }));
|
||||
|
||||
const imgRef = useRef<HTMLImageElement | null>(null);
|
||||
const isPlaceholder = !src || src === PLACEHOLDER_SRC || src.startsWith('data:image/svg');
|
||||
|
||||
// Handle drag-and-drop of files directly onto the image
|
||||
const handleDrop = useCallback(async (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const file = e.dataTransfer.files?.[0];
|
||||
if (file && file.type.startsWith('image/')) {
|
||||
const url = await uploadToWhp(file);
|
||||
if (url) setProp((p: ImageBlockProps) => { p.src = url; });
|
||||
}
|
||||
}, [setProp]);
|
||||
|
||||
const handleDragOver = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = 'copy';
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<img
|
||||
ref={(ref: HTMLImageElement | null) => {
|
||||
imgRef.current = ref;
|
||||
if (ref) connect(drag(ref));
|
||||
}}
|
||||
src={src || PLACEHOLDER_SRC}
|
||||
alt={alt || 'Image'}
|
||||
onDrop={handleDrop}
|
||||
onDragOver={handleDragOver}
|
||||
style={{
|
||||
display: 'block',
|
||||
maxWidth: '100%',
|
||||
outline: 'none',
|
||||
cursor: selected ? 'move' : 'pointer',
|
||||
...style,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
ImageBlock.craft = {
|
||||
displayName: 'Image',
|
||||
props: { src: PLACEHOLDER_SRC, alt: '', style: { width: '100%', height: 'auto' } },
|
||||
rules: { canDrag: () => true, canMoveIn: () => false, canMoveOut: () => true },
|
||||
};
|
||||
|
||||
(ImageBlock as any).toHtml = (props: ImageBlockProps, _c: string) => {
|
||||
// Skip placeholder/empty images in export
|
||||
const src = props.src || '';
|
||||
if (!src || src.startsWith('data:image/svg') || src === PLACEHOLDER_SRC) {
|
||||
return { html: '' };
|
||||
}
|
||||
const s = cssPropsToString({ display: 'block', maxWidth: '100%', ...props.style });
|
||||
const alt = props.alt ? ` alt="${escapeAttr(props.alt)}"` : ' alt=""';
|
||||
return { html: `<img src="${escapeAttr(safeImageUrl(src))}"${alt}${s ? ` style="${s}"` : ''} />` };
|
||||
};
|
||||
@@ -0,0 +1,67 @@
|
||||
import { describe, test, expect } from 'vitest';
|
||||
import { MapEmbed } from './MapEmbed';
|
||||
|
||||
const toHtml = (MapEmbed as any).toHtml;
|
||||
|
||||
describe('MapEmbed.toHtml iframe accessibility (F2.4)', () => {
|
||||
test('iframe has a non-empty title attribute', () => {
|
||||
const { html } = toHtml({ address: 'New York, NY' }, '');
|
||||
expect(html).toMatch(/<iframe[^>]*title="[^"]+"/);
|
||||
});
|
||||
|
||||
test('title reflects the configured address', () => {
|
||||
const { html } = toHtml({ address: 'Golden Gate Bridge' }, '');
|
||||
expect(html).toContain('title="Map of Golden Gate Bridge"');
|
||||
});
|
||||
});
|
||||
|
||||
describe('MapEmbed.toHtml iframe src ampersand encoding (F-export review Minor)', () => {
|
||||
test('the iframe src (built by string concatenation with literal &) emits & in the attribute, not a raw &', () => {
|
||||
const { html } = toHtml({ address: 'New York, NY', zoom: 14 }, '');
|
||||
const srcMatch = html.match(/<iframe src="([^"]+)"/);
|
||||
expect(srcMatch).toBeTruthy();
|
||||
// The raw src is `...q=...&z=14&output=embed` -- concatenated with
|
||||
// literal `&`s -- so the emitted attribute must HTML-encode them.
|
||||
expect(srcMatch![1]).toContain('&z=14');
|
||||
expect(srcMatch![1]).toContain('&output=embed');
|
||||
expect(srcMatch![1]).not.toMatch(/&(?!amp;)/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('MapEmbed.toHtml address/zoom/height XSS hardening', () => {
|
||||
test('a malicious address cannot break out of the src or title attribute', () => {
|
||||
const malicious = 'X" onerror="alert(1)';
|
||||
const { html } = toHtml({ address: malicious }, '');
|
||||
expect(html).not.toContain('onerror="alert(1)"');
|
||||
});
|
||||
|
||||
test('a wrong-typed zoom (string with attribute-breakout chars) cannot break out of the src attribute', () => {
|
||||
const malicious = '14"><script>alert(1)</script>' as any;
|
||||
const { html } = toHtml({ address: 'X', zoom: malicious }, '');
|
||||
expect(html).not.toContain('<script>alert(1)</script>');
|
||||
expect(html).not.toContain('"><script');
|
||||
});
|
||||
|
||||
test('a wrong-typed zoom is coerced to a safe numeric value in the exported URL (defense in depth beyond escaping)', () => {
|
||||
const malicious = '14"><script>alert(1)</script>' as any;
|
||||
const { html } = toHtml({ address: 'X', zoom: malicious }, '');
|
||||
const srcMatch = html.match(/<iframe src="([^"]+)"/);
|
||||
expect(srcMatch).toBeTruthy();
|
||||
// Decode the entity-escaped src back to a plain string and confirm the
|
||||
// `z=` param is a bare, well-formed number -- not the raw attacker string.
|
||||
const decoded = srcMatch![1].replace(/&/g, '&').replace(/"/g, '"').replace(/</g, '<').replace(/>/g, '>');
|
||||
expect(decoded).toMatch(/[&?]z=\d+(&|$)/);
|
||||
});
|
||||
|
||||
test('a malicious height cannot break out of the iframe style attribute', () => {
|
||||
const malicious = '400px" onmouseover="alert(1)';
|
||||
const { html } = toHtml({ address: 'X', height: malicious }, '');
|
||||
expect(html).not.toContain('onmouseover="alert(1)"');
|
||||
});
|
||||
|
||||
test('a normal zoom/height still renders correctly', () => {
|
||||
const { html } = toHtml({ address: 'X', zoom: 10, height: '300px' }, '');
|
||||
expect(html).toContain('z=10');
|
||||
expect(html).toContain('height:300px');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,106 @@
|
||||
import React, { CSSProperties } from 'react';
|
||||
import { useNode, UserComponent } from '@craftjs/core';
|
||||
import { cssPropsToString } from '../../utils/style-helpers';
|
||||
import { escapeAttr, safeUrl } from '../../utils/escape';
|
||||
|
||||
interface MapEmbedProps {
|
||||
address?: string;
|
||||
zoom?: number;
|
||||
height?: string;
|
||||
style?: CSSProperties;
|
||||
}
|
||||
|
||||
function buildMapUrl(address: string, zoom: number): string {
|
||||
const encoded = encodeURIComponent(address);
|
||||
// `zoom` is declared as `number` but is not runtime-type-checked (AI
|
||||
// update_props / deserialized state can hand us anything). The final src
|
||||
// string is still run through escapeAttr(safeUrl(...)) at the toHtml call
|
||||
// site, which already blocks attribute-breakout -- but Number-coercing
|
||||
// here too keeps the emitted URL a well-formed `z=<digits>` query param
|
||||
// instead of smuggling arbitrary attacker text into it.
|
||||
const z = Number(zoom);
|
||||
const safeZoom = Number.isFinite(z) ? z : 14;
|
||||
return `https://maps.google.com/maps?q=${encoded}&z=${safeZoom}&output=embed`;
|
||||
}
|
||||
|
||||
export const MapEmbed: UserComponent<MapEmbedProps> = ({
|
||||
address = 'New York, NY',
|
||||
zoom = 14,
|
||||
height = '400px',
|
||||
style = {},
|
||||
}) => {
|
||||
const {
|
||||
connectors: { connect, drag },
|
||||
selected,
|
||||
} = useNode((node) => ({
|
||||
selected: node.events.selected,
|
||||
}));
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={(ref: HTMLDivElement | null): void => { if (ref) connect(drag(ref)); }}
|
||||
style={{
|
||||
width: '100%',
|
||||
outline: selected ? '2px solid #3b82f6' : 'none',
|
||||
...style,
|
||||
}}
|
||||
>
|
||||
<iframe
|
||||
src={buildMapUrl(address, zoom)}
|
||||
style={{
|
||||
width: '100%',
|
||||
height,
|
||||
border: 'none',
|
||||
borderRadius: (style as any)?.borderRadius || '0px',
|
||||
display: 'block',
|
||||
}}
|
||||
loading="lazy"
|
||||
referrerPolicy="no-referrer-when-downgrade"
|
||||
allowFullScreen
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/* ---------- Craft config ---------- */
|
||||
|
||||
MapEmbed.craft = {
|
||||
displayName: 'Map',
|
||||
props: {
|
||||
address: 'New York, NY',
|
||||
zoom: 14,
|
||||
height: '400px',
|
||||
style: {},
|
||||
},
|
||||
rules: {
|
||||
canDrag: () => true,
|
||||
canMoveIn: () => false,
|
||||
canMoveOut: () => true,
|
||||
},
|
||||
};
|
||||
|
||||
/* ---------- HTML export ---------- */
|
||||
|
||||
(MapEmbed as any).toHtml = (props: MapEmbedProps, _childrenHtml: string) => {
|
||||
const {
|
||||
address = 'New York, NY',
|
||||
zoom = 14,
|
||||
height = '400px',
|
||||
style = {},
|
||||
} = props;
|
||||
|
||||
const wrapperStyle = cssPropsToString({ width: '100%', ...style });
|
||||
const iframeStyle = cssPropsToString({
|
||||
width: '100%',
|
||||
height,
|
||||
border: 'none',
|
||||
borderRadius: (style as any)?.borderRadius || undefined,
|
||||
display: 'block',
|
||||
});
|
||||
|
||||
const src = buildMapUrl(address, zoom);
|
||||
|
||||
return {
|
||||
html: `<div${wrapperStyle ? ` style="${wrapperStyle}"` : ''}><iframe src="${escapeAttr(safeUrl(src))}" title="${escapeAttr(`Map of ${address}`)}" loading="lazy" referrerpolicy="no-referrer-when-downgrade" allowfullscreen${iframeStyle ? ` style="${iframeStyle}"` : ''}></iframe></div>`,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,120 @@
|
||||
import { describe, test, expect } from 'vitest';
|
||||
import { VideoBlock } from './VideoBlock';
|
||||
|
||||
const toHtml = (VideoBlock as any).toHtml;
|
||||
|
||||
function embedSrc(videoUrl: string): string {
|
||||
const { html } = toHtml({ videoUrl }, '');
|
||||
const m = html.match(/<iframe src="([^"]+)"/) || html.match(/<video src="([^"]+)"/);
|
||||
return m ? m[1].replace(/&/g, '&') : '';
|
||||
}
|
||||
|
||||
describe('VideoBlock URL parsing (D4)', () => {
|
||||
test('youtube.com/watch?v=ID (existing case) resolves to embed URL', () => {
|
||||
expect(embedSrc('https://www.youtube.com/watch?v=dQw4w9WgXcQ')).toContain('youtube.com/embed/dQw4w9WgXcQ');
|
||||
});
|
||||
|
||||
test('youtu.be/ID resolves to embed URL', () => {
|
||||
expect(embedSrc('https://youtu.be/dQw4w9WgXcQ')).toContain('youtube.com/embed/dQw4w9WgXcQ');
|
||||
});
|
||||
|
||||
test('youtube.com/embed/ID (existing case) resolves to embed URL', () => {
|
||||
expect(embedSrc('https://www.youtube.com/embed/dQw4w9WgXcQ')).toContain('youtube.com/embed/dQw4w9WgXcQ');
|
||||
});
|
||||
|
||||
test('youtube.com/shorts/ID resolves to embed URL', () => {
|
||||
expect(embedSrc('https://www.youtube.com/shorts/dQw4w9WgXcQ')).toContain('youtube.com/embed/dQw4w9WgXcQ');
|
||||
});
|
||||
|
||||
test('youtube.com/live/ID resolves to embed URL', () => {
|
||||
expect(embedSrc('https://www.youtube.com/live/dQw4w9WgXcQ')).toContain('youtube.com/embed/dQw4w9WgXcQ');
|
||||
});
|
||||
|
||||
test('youtube.com/watch?...&v=ID (v not first param) resolves to embed URL', () => {
|
||||
expect(embedSrc('https://www.youtube.com/watch?list=PLxyz&v=dQw4w9WgXcQ&index=3')).toContain('youtube.com/embed/dQw4w9WgXcQ');
|
||||
});
|
||||
|
||||
test('vimeo.com/ID (existing case) resolves to player URL', () => {
|
||||
expect(embedSrc('https://vimeo.com/123456789')).toContain('https://player.vimeo.com/video/123456789');
|
||||
});
|
||||
|
||||
test('vimeo.com/ID/HASH (private video) resolves to player URL with hash param', () => {
|
||||
const src = embedSrc('https://vimeo.com/123456789/abcdef1234');
|
||||
expect(src).toContain('https://player.vimeo.com/video/123456789');
|
||||
expect(src).toContain('h=abcdef1234');
|
||||
});
|
||||
|
||||
test('direct .mp4 file still works', () => {
|
||||
const { html } = toHtml({ videoUrl: 'https://example.com/clip.mp4' }, '');
|
||||
expect(html).toContain('<video src="https://example.com/clip.mp4"');
|
||||
});
|
||||
|
||||
test('unrecognized URL yields no output (type "none")', () => {
|
||||
const { html } = toHtml({ videoUrl: 'not-a-real-video-url' }, '');
|
||||
expect(html).toBe('');
|
||||
});
|
||||
|
||||
test('emitted src is safeUrl-wrapped: javascript: scheme never reaches output', () => {
|
||||
const { html } = toHtml({ videoUrl: 'javascript:alert(1)' }, '');
|
||||
expect(html).not.toContain('javascript:');
|
||||
});
|
||||
});
|
||||
|
||||
describe('VideoBlock.toHtml iframe accessibility (F2.4)', () => {
|
||||
test('normal-mode YouTube/Vimeo iframe has a title attribute', () => {
|
||||
const { html } = toHtml({ videoUrl: 'https://www.youtube.com/watch?v=dQw4w9WgXcQ' }, '');
|
||||
expect(html).toMatch(/<iframe[^>]*title="[^"]+"/);
|
||||
});
|
||||
|
||||
test('background-mode YouTube/Vimeo iframe has a title attribute', () => {
|
||||
const { html } = toHtml({ videoUrl: 'https://vimeo.com/123456789', isBackground: true }, '');
|
||||
expect(html).toMatch(/<iframe[^>]*title="[^"]+"/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('VideoBlock.toHtml overlay/innerMaxWidth XSS hardening (background mode)', () => {
|
||||
test('a malicious overlayColor cannot break out of the overlay style attribute', () => {
|
||||
const malicious = 'red" onmouseover="alert(1)';
|
||||
const { html } = toHtml({ videoUrl: 'https://vimeo.com/123456789', isBackground: true, overlayColor: malicious }, '');
|
||||
expect(html).not.toContain('onmouseover="alert(1)"');
|
||||
});
|
||||
|
||||
test('a wrong-typed overlayOpacity (string, not number) cannot break out of the overlay style attribute', () => {
|
||||
const malicious = '50" onmouseover="alert(1)' as any;
|
||||
const { html } = toHtml({ videoUrl: 'https://vimeo.com/123456789', isBackground: true, overlayOpacity: malicious }, '');
|
||||
expect(html).not.toContain('onmouseover="alert(1)"');
|
||||
});
|
||||
|
||||
test('a malicious innerMaxWidth cannot break out of the inner style attribute', () => {
|
||||
const malicious = '1200px" onmouseover="alert(1)';
|
||||
const { html } = toHtml({ videoUrl: 'https://vimeo.com/123456789', isBackground: true, innerMaxWidth: malicious }, '');
|
||||
expect(html).not.toContain('onmouseover="alert(1)"');
|
||||
});
|
||||
|
||||
test('a malicious style.borderRadius cannot break out of the style attribute (normal mode, iframe wrapper)', () => {
|
||||
const malicious = { borderRadius: '8px" onmouseover="alert(1)' } as any;
|
||||
const { html } = toHtml({ videoUrl: 'https://vimeo.com/123456789', style: malicious }, '');
|
||||
expect(html).not.toContain('onmouseover="alert(1)"');
|
||||
});
|
||||
|
||||
test('a malicious style.borderRadius cannot break out of the style attribute (direct file <video>)', () => {
|
||||
const malicious = { borderRadius: '8px" onmouseover="alert(1)' } as any;
|
||||
const { html } = toHtml({ videoUrl: 'https://example.com/clip.mp4', style: malicious }, '');
|
||||
expect(html).not.toContain('onmouseover="alert(1)"');
|
||||
});
|
||||
});
|
||||
|
||||
describe('VideoBlock.toHtml iframe src ampersand encoding (F-export review Minor)', () => {
|
||||
test('embed params joined with literal & are HTML-entity-encoded in the emitted src attribute', () => {
|
||||
// autoplay+muted+controls=false forces buildEmbedParams to concatenate
|
||||
// multiple query params onto the URL with literal `&`s.
|
||||
const { html } = toHtml(
|
||||
{ videoUrl: 'https://www.youtube.com/watch?v=dQw4w9WgXcQ', autoplay: true, muted: true, controls: false },
|
||||
''
|
||||
);
|
||||
const srcMatch = html.match(/<iframe src="([^"]+)"/);
|
||||
expect(srcMatch).toBeTruthy();
|
||||
expect(srcMatch![1]).toMatch(/&/);
|
||||
expect(srcMatch![1]).not.toMatch(/&(?!amp;)/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,459 @@
|
||||
import React, { CSSProperties } from 'react';
|
||||
import { useNode, Element, UserComponent } from '@craftjs/core';
|
||||
import { Container } from '../layout/Container';
|
||||
import { cssPropsToString } from '../../utils/style-helpers';
|
||||
import { escapeAttr, safeUrl } from '../../utils/escape';
|
||||
|
||||
/* ---------- Types ---------- */
|
||||
|
||||
type VideoType = 'youtube' | 'vimeo' | 'file' | 'none';
|
||||
|
||||
interface VideoBlockProps {
|
||||
videoUrl?: string;
|
||||
videoType?: VideoType;
|
||||
embedUrl?: string;
|
||||
autoplay?: boolean;
|
||||
muted?: boolean;
|
||||
loop?: boolean;
|
||||
controls?: boolean;
|
||||
isBackground?: boolean;
|
||||
overlayColor?: string;
|
||||
overlayOpacity?: number;
|
||||
innerMaxWidth?: string;
|
||||
style?: CSSProperties;
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
/* ---------- URL detection ---------- */
|
||||
|
||||
/**
|
||||
* Extract a YouTube video ID from any of the URL shapes WHP users paste:
|
||||
* youtu.be/ID, youtube.com/embed/ID, youtube.com/shorts/ID,
|
||||
* youtube.com/live/ID (path-based), and youtube.com/watch?...v=ID where `v`
|
||||
* may appear anywhere in the query string (not just as the first param).
|
||||
*/
|
||||
function extractYouTubeId(url: string): string | null {
|
||||
const pathMatch = url.match(
|
||||
/(?:youtube\.com\/(?:embed|shorts|live)\/|youtu\.be\/)([a-zA-Z0-9_-]+)/
|
||||
);
|
||||
if (pathMatch) return pathMatch[1];
|
||||
|
||||
const queryMatch = url.match(/youtube\.com\/watch\?([^\s#]+)/);
|
||||
if (queryMatch) {
|
||||
const v = new URLSearchParams(queryMatch[1]).get('v');
|
||||
if (v) return v;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function detectVideoType(url: string): { type: VideoType; embedUrl: string } {
|
||||
if (!url) return { type: 'none', embedUrl: '' };
|
||||
|
||||
// YouTube
|
||||
const ytId = extractYouTubeId(url);
|
||||
if (ytId) return { type: 'youtube', embedUrl: `https://www.youtube.com/embed/${ytId}?rel=0` };
|
||||
|
||||
// Vimeo: vimeo.com/ID, or vimeo.com/ID/HASH for unlisted/private videos
|
||||
// (the hash becomes the player's `h` query param).
|
||||
const vmMatch = url.match(/vimeo\.com\/(\d+)(?:\/([a-zA-Z0-9]+))?/);
|
||||
if (vmMatch) {
|
||||
const embedUrl = vmMatch[2]
|
||||
? `https://player.vimeo.com/video/${vmMatch[1]}?h=${vmMatch[2]}`
|
||||
: `https://player.vimeo.com/video/${vmMatch[1]}`;
|
||||
return { type: 'vimeo', embedUrl };
|
||||
}
|
||||
|
||||
// Direct file
|
||||
if (url.match(/\.(mp4|webm|ogg|mov)(\?|$)/i)) return { type: 'file', embedUrl: url };
|
||||
|
||||
// Uploaded asset (proxy URL)
|
||||
if (url.includes('assets-proxy') || url.includes('serve_asset')) return { type: 'file', embedUrl: url };
|
||||
|
||||
return { type: 'none', embedUrl: url };
|
||||
}
|
||||
|
||||
/** Build embed params for YouTube/Vimeo iframes */
|
||||
function buildEmbedParams(
|
||||
baseUrl: string,
|
||||
opts: { autoplay?: boolean; muted?: boolean; loop?: boolean; controls?: boolean }
|
||||
): string {
|
||||
const url = new URL(baseUrl);
|
||||
if (opts.autoplay) url.searchParams.set('autoplay', '1');
|
||||
if (opts.muted) url.searchParams.set('mute', '1');
|
||||
if (opts.loop) url.searchParams.set('loop', '1');
|
||||
if (opts.controls === false) url.searchParams.set('controls', '0');
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
/* ---------- Placeholder ---------- */
|
||||
|
||||
const VIDEO_PLACEHOLDER = (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
flexDirection: 'column',
|
||||
gap: 8,
|
||||
width: '100%',
|
||||
aspectRatio: '16 / 9',
|
||||
background: '#27272a',
|
||||
borderRadius: 8,
|
||||
border: '2px dashed #3f3f46',
|
||||
color: '#71717a',
|
||||
fontFamily: 'sans-serif',
|
||||
fontSize: 14,
|
||||
textAlign: 'center' as const,
|
||||
}}
|
||||
>
|
||||
<i className="fa fa-play-circle" style={{ fontSize: 36, opacity: 0.5 }} />
|
||||
<span>Add a video URL in settings</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
/* ========================================================================
|
||||
Normal (non-background) Video Component
|
||||
======================================================================== */
|
||||
|
||||
export const VideoBlock: UserComponent<VideoBlockProps> = ({
|
||||
videoUrl = '',
|
||||
videoType: _videoTypeProp,
|
||||
embedUrl: _embedUrlProp,
|
||||
autoplay = false,
|
||||
muted = true,
|
||||
loop = false,
|
||||
controls = true,
|
||||
isBackground = false,
|
||||
overlayColor = '#000000',
|
||||
overlayOpacity = 50,
|
||||
innerMaxWidth = '1200px',
|
||||
style = {},
|
||||
}) => {
|
||||
const {
|
||||
connectors: { connect, drag },
|
||||
} = useNode();
|
||||
|
||||
// Detect type from URL
|
||||
const { type, embedUrl } = videoUrl ? detectVideoType(videoUrl) : { type: 'none' as VideoType, embedUrl: '' };
|
||||
|
||||
/* ---- Background mode ---- */
|
||||
if (isBackground) {
|
||||
return (
|
||||
<section
|
||||
ref={(ref: HTMLElement | null): void => {
|
||||
if (ref) connect(drag(ref));
|
||||
}}
|
||||
style={{
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
minHeight: '300px',
|
||||
overflow: 'hidden',
|
||||
...style,
|
||||
}}
|
||||
>
|
||||
{/* Background video layer */}
|
||||
{type === 'file' && embedUrl && (
|
||||
<video
|
||||
src={embedUrl}
|
||||
autoPlay
|
||||
muted
|
||||
loop
|
||||
playsInline
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '50%',
|
||||
left: '50%',
|
||||
minWidth: '100%',
|
||||
minHeight: '100%',
|
||||
width: 'auto',
|
||||
height: 'auto',
|
||||
transform: 'translate(-50%, -50%)',
|
||||
objectFit: 'cover',
|
||||
zIndex: 0,
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{(type === 'youtube' || type === 'vimeo') && embedUrl && (
|
||||
<iframe
|
||||
src={buildEmbedParams(embedUrl, { autoplay: true, muted: true, loop: true, controls: false })}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '50%',
|
||||
left: '50%',
|
||||
width: '177.78vh', // 16:9 ratio overflow
|
||||
height: '100vh',
|
||||
minWidth: '100%',
|
||||
minHeight: '100%',
|
||||
transform: 'translate(-50%, -50%)',
|
||||
border: 'none',
|
||||
zIndex: 0,
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
allow="autoplay; encrypted-media"
|
||||
allowFullScreen
|
||||
/>
|
||||
)}
|
||||
{type === 'none' && (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
background: '#1e293b',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
color: '#71717a',
|
||||
fontSize: 14,
|
||||
fontFamily: 'sans-serif',
|
||||
zIndex: 0,
|
||||
}}
|
||||
>
|
||||
<i className="fa fa-film" style={{ fontSize: 48, opacity: 0.3 }} />
|
||||
</div>
|
||||
)}
|
||||
{/* Overlay */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
backgroundColor: overlayColor,
|
||||
opacity: (overlayOpacity ?? 50) / 100,
|
||||
zIndex: 1,
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
/>
|
||||
{/* Content drop zone */}
|
||||
<Element
|
||||
id="video-bg-inner"
|
||||
is={Container}
|
||||
canvas
|
||||
style={{
|
||||
position: 'relative',
|
||||
zIndex: 2,
|
||||
maxWidth: innerMaxWidth,
|
||||
margin: '0 auto',
|
||||
padding: '80px 20px',
|
||||
}}
|
||||
tag="div"
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---- Normal mode ---- */
|
||||
return (
|
||||
<div
|
||||
ref={(ref: HTMLDivElement | null): void => {
|
||||
if (ref) connect(drag(ref));
|
||||
}}
|
||||
style={{
|
||||
width: '100%',
|
||||
...style,
|
||||
}}
|
||||
>
|
||||
{type === 'none' && VIDEO_PLACEHOLDER}
|
||||
|
||||
{(type === 'youtube' || type === 'vimeo') && (
|
||||
<div
|
||||
style={{
|
||||
position: 'relative',
|
||||
paddingBottom: '56.25%',
|
||||
height: 0,
|
||||
overflow: 'hidden',
|
||||
borderRadius: (style as any)?.borderRadius || undefined,
|
||||
}}
|
||||
>
|
||||
<iframe
|
||||
src={buildEmbedParams(embedUrl, { autoplay, muted, loop, controls })}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
border: 'none',
|
||||
}}
|
||||
allow="autoplay; encrypted-media; picture-in-picture"
|
||||
allowFullScreen
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{type === 'file' && (
|
||||
<video
|
||||
src={embedUrl}
|
||||
autoPlay={autoplay}
|
||||
muted={muted}
|
||||
loop={loop}
|
||||
controls={controls}
|
||||
playsInline
|
||||
style={{
|
||||
display: 'block',
|
||||
width: '100%',
|
||||
borderRadius: (style as any)?.borderRadius || undefined,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/* ========================================================================
|
||||
Craft Config
|
||||
======================================================================== */
|
||||
|
||||
VideoBlock.craft = {
|
||||
displayName: 'Video',
|
||||
props: {
|
||||
videoUrl: '',
|
||||
videoType: 'none',
|
||||
embedUrl: '',
|
||||
autoplay: false,
|
||||
muted: true,
|
||||
loop: false,
|
||||
controls: true,
|
||||
isBackground: false,
|
||||
overlayColor: '#000000',
|
||||
overlayOpacity: 50,
|
||||
innerMaxWidth: '1200px',
|
||||
style: {},
|
||||
},
|
||||
rules: {
|
||||
canDrag: () => true,
|
||||
canMoveIn: () => true,
|
||||
canMoveOut: () => true,
|
||||
},
|
||||
};
|
||||
|
||||
/* ========================================================================
|
||||
HTML Export
|
||||
======================================================================== */
|
||||
|
||||
(VideoBlock as any).toHtml = (props: VideoBlockProps, childrenHtml: string) => {
|
||||
const {
|
||||
videoUrl = '',
|
||||
autoplay = false,
|
||||
muted = true,
|
||||
loop: doLoop = false,
|
||||
controls = true,
|
||||
isBackground = false,
|
||||
overlayColor = '#000000',
|
||||
overlayOpacity = 50,
|
||||
innerMaxWidth = '1200px',
|
||||
style = {},
|
||||
} = props;
|
||||
|
||||
const { type, embedUrl } = videoUrl ? detectVideoType(videoUrl) : { type: 'none' as VideoType, embedUrl: '' };
|
||||
|
||||
/* ---- Background mode export ---- */
|
||||
if (isBackground) {
|
||||
const outerStyle = cssPropsToString({
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
minHeight: '300px',
|
||||
overflow: 'hidden',
|
||||
...style,
|
||||
});
|
||||
const overlayStyle = cssPropsToString({
|
||||
position: 'absolute',
|
||||
inset: '0',
|
||||
backgroundColor: overlayColor,
|
||||
opacity: String(overlayOpacity / 100),
|
||||
zIndex: '1',
|
||||
pointerEvents: 'none',
|
||||
});
|
||||
const innerStyle = cssPropsToString({
|
||||
position: 'relative',
|
||||
zIndex: '2',
|
||||
maxWidth: innerMaxWidth,
|
||||
margin: '0 auto',
|
||||
padding: '80px 20px',
|
||||
});
|
||||
|
||||
let videoHtml = '';
|
||||
if (type === 'file' && embedUrl) {
|
||||
const vidStyle = cssPropsToString({
|
||||
position: 'absolute',
|
||||
top: '50%',
|
||||
left: '50%',
|
||||
minWidth: '100%',
|
||||
minHeight: '100%',
|
||||
width: 'auto',
|
||||
height: 'auto',
|
||||
transform: 'translate(-50%, -50%)',
|
||||
objectFit: 'cover',
|
||||
zIndex: '0',
|
||||
pointerEvents: 'none',
|
||||
});
|
||||
videoHtml = `<video src="${escapeAttr(safeUrl(embedUrl))}" autoplay muted loop playsinline${vidStyle ? ` style="${vidStyle}"` : ''}></video>`;
|
||||
} else if ((type === 'youtube' || type === 'vimeo') && embedUrl) {
|
||||
const iframeSrc = buildEmbedParams(embedUrl, { autoplay: true, muted: true, loop: true, controls: false });
|
||||
const ifrStyle = cssPropsToString({
|
||||
position: 'absolute',
|
||||
top: '50%',
|
||||
left: '50%',
|
||||
width: '177.78vh',
|
||||
height: '100vh',
|
||||
minWidth: '100%',
|
||||
minHeight: '100%',
|
||||
transform: 'translate(-50%, -50%)',
|
||||
border: 'none',
|
||||
zIndex: '0',
|
||||
pointerEvents: 'none',
|
||||
});
|
||||
videoHtml = `<iframe src="${escapeAttr(safeUrl(iframeSrc))}" title="Embedded video" allow="autoplay; encrypted-media" allowfullscreen${ifrStyle ? ` style="${ifrStyle}"` : ''}></iframe>`;
|
||||
}
|
||||
|
||||
return {
|
||||
html: `<section${outerStyle ? ` style="${outerStyle}"` : ''}>${videoHtml}<div${overlayStyle ? ` style="${overlayStyle}"` : ''}></div><div${innerStyle ? ` style="${innerStyle}"` : ''}>${childrenHtml}</div></section>`,
|
||||
};
|
||||
}
|
||||
|
||||
/* ---- Normal mode export ---- */
|
||||
const wrapperStyle = cssPropsToString({ width: '100%', ...style });
|
||||
|
||||
if (type === 'none' || !embedUrl) {
|
||||
return { html: '' };
|
||||
}
|
||||
|
||||
if (type === 'youtube' || type === 'vimeo') {
|
||||
const iframeSrc = buildEmbedParams(embedUrl, { autoplay, muted, loop: doLoop, controls });
|
||||
const containerStyle = cssPropsToString({
|
||||
position: 'relative',
|
||||
paddingBottom: '56.25%',
|
||||
height: '0',
|
||||
overflow: 'hidden',
|
||||
borderRadius: (style as any)?.borderRadius || undefined,
|
||||
});
|
||||
const iframeStyle = cssPropsToString({
|
||||
position: 'absolute',
|
||||
top: '0',
|
||||
left: '0',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
border: 'none',
|
||||
});
|
||||
return {
|
||||
html: `<div${wrapperStyle ? ` style="${wrapperStyle}"` : ''}><div${containerStyle ? ` style="${containerStyle}"` : ''}><iframe src="${escapeAttr(safeUrl(iframeSrc))}" title="Embedded video" allow="autoplay; encrypted-media; picture-in-picture" allowfullscreen${iframeStyle ? ` style="${iframeStyle}"` : ''}></iframe></div></div>`,
|
||||
};
|
||||
}
|
||||
|
||||
// Direct file
|
||||
const vidAttrs: string[] = [];
|
||||
if (autoplay) vidAttrs.push('autoplay');
|
||||
if (muted) vidAttrs.push('muted');
|
||||
if (doLoop) vidAttrs.push('loop');
|
||||
if (controls) vidAttrs.push('controls');
|
||||
vidAttrs.push('playsinline');
|
||||
const vidStyle = cssPropsToString({
|
||||
display: 'block',
|
||||
width: '100%',
|
||||
borderRadius: (style as any)?.borderRadius || undefined,
|
||||
});
|
||||
|
||||
return {
|
||||
html: `<div${wrapperStyle ? ` style="${wrapperStyle}"` : ''}><video src="${escapeAttr(safeUrl(embedUrl))}" ${vidAttrs.join(' ')}${vidStyle ? ` style="${vidStyle}"` : ''}></video></div>`,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,81 @@
|
||||
import { Container } from './layout/Container';
|
||||
import { Section } from './layout/Section';
|
||||
import { ColumnLayout } from './layout/ColumnLayout';
|
||||
import { BackgroundSection } from './layout/BackgroundSection';
|
||||
import { Heading } from './basic/Heading';
|
||||
import { TextBlock } from './basic/TextBlock';
|
||||
import { ButtonLink } from './basic/ButtonLink';
|
||||
import { Logo } from './basic/Logo';
|
||||
import { Menu } from './basic/Menu';
|
||||
import { Navbar } from './basic/Navbar';
|
||||
import { Footer } from './basic/Footer';
|
||||
import { Divider } from './basic/Divider';
|
||||
import { Spacer } from './basic/Spacer';
|
||||
import { Icon } from './basic/Icon';
|
||||
import { ImageBlock } from './media/ImageBlock';
|
||||
import { VideoBlock } from './media/VideoBlock';
|
||||
import { MapEmbed } from './media/MapEmbed';
|
||||
import { HeroSimple } from './sections/HeroSimple';
|
||||
import { FeaturesGrid } from './sections/FeaturesGrid';
|
||||
import { CTASection } from './sections/CTASection';
|
||||
import { Countdown } from './sections/Countdown';
|
||||
import { Testimonials } from './sections/Testimonials';
|
||||
import { FormContainer } from './forms/FormContainer';
|
||||
import { InputField } from './forms/InputField';
|
||||
import { TextareaField } from './forms/TextareaField';
|
||||
import { FormButton } from './forms/FormButton';
|
||||
import { ContactForm } from './forms/ContactForm';
|
||||
import { StarRating } from './basic/StarRating';
|
||||
import { SocialLinks } from './basic/SocialLinks';
|
||||
import { CallToAction } from './sections/CallToAction';
|
||||
import { Accordion } from './sections/Accordion';
|
||||
import { Tabs } from './sections/Tabs';
|
||||
import { PricingTable } from './sections/PricingTable';
|
||||
import { Gallery } from './sections/Gallery';
|
||||
import { ContentSlider } from './sections/ContentSlider';
|
||||
import { NumberCounter } from './sections/NumberCounter';
|
||||
import { SubscribeForm } from './forms/SubscribeForm';
|
||||
import { SearchBar } from './basic/SearchBar';
|
||||
import { HtmlBlock } from './basic/HtmlBlock';
|
||||
|
||||
export const componentResolver = {
|
||||
Container,
|
||||
Section,
|
||||
ColumnLayout,
|
||||
BackgroundSection,
|
||||
Heading,
|
||||
TextBlock,
|
||||
ButtonLink,
|
||||
Logo,
|
||||
Menu,
|
||||
Navbar,
|
||||
Footer,
|
||||
Divider,
|
||||
Spacer,
|
||||
Icon,
|
||||
ImageBlock,
|
||||
VideoBlock,
|
||||
MapEmbed,
|
||||
HeroSimple,
|
||||
FeaturesGrid,
|
||||
CTASection,
|
||||
Countdown,
|
||||
Testimonials,
|
||||
FormContainer,
|
||||
InputField,
|
||||
TextareaField,
|
||||
FormButton,
|
||||
ContactForm,
|
||||
StarRating,
|
||||
SocialLinks,
|
||||
CallToAction,
|
||||
Accordion,
|
||||
Tabs,
|
||||
PricingTable,
|
||||
Gallery,
|
||||
ContentSlider,
|
||||
NumberCounter,
|
||||
SubscribeForm,
|
||||
SearchBar,
|
||||
HtmlBlock,
|
||||
};
|
||||
@@ -0,0 +1,38 @@
|
||||
import { describe, test, expect } from 'vitest';
|
||||
import { Accordion } from './Accordion';
|
||||
|
||||
const toHtml = (Accordion as any).toHtml;
|
||||
|
||||
const items = [
|
||||
{ title: 'Q1', content: 'A1', isOpen: true },
|
||||
{ title: 'Q2', content: 'A2', isOpen: false },
|
||||
];
|
||||
|
||||
describe('Accordion.toHtml basic export', () => {
|
||||
test('renders a <details> per item with the open attribute honored', () => {
|
||||
const { html } = toHtml({ items }, '');
|
||||
const detailsBlocks = html.match(/<details[^>]*>/g) || [];
|
||||
expect(detailsBlocks.length).toBe(2);
|
||||
expect(detailsBlocks[0]).toContain(' open');
|
||||
expect(detailsBlocks[1]).not.toContain(' open');
|
||||
});
|
||||
|
||||
test('headerBg/headerColor/contentBg/borderColor emit into the panel styles', () => {
|
||||
const { html } = toHtml({ items, headerBg: '#111111', headerColor: '#222222', contentBg: '#333333', borderColor: '#444444' }, '');
|
||||
expect(html).toContain('#111111');
|
||||
expect(html).toContain('#222222');
|
||||
expect(html).toContain('#333333');
|
||||
expect(html).toContain('#444444');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Accordion.craft.props includes the box-model/animation/visibility rollout props', () => {
|
||||
test('animation, animationDelay, and all 3 hideOn* flags are declared (blank/false defaults)', () => {
|
||||
const props = (Accordion as any).craft.props;
|
||||
expect(props).toHaveProperty('animation', '');
|
||||
expect(props).toHaveProperty('animationDelay', '');
|
||||
expect(props).toHaveProperty('hideOnDesktop', false);
|
||||
expect(props).toHaveProperty('hideOnTablet', false);
|
||||
expect(props).toHaveProperty('hideOnMobile', false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,195 @@
|
||||
import React, { CSSProperties, useState } from 'react';
|
||||
import { useNode, UserComponent } from '@craftjs/core';
|
||||
import { cssPropsToString } from '../../utils/style-helpers';
|
||||
import { escapeHtml, escapeAttr, cssValue } from '../../utils/escape';
|
||||
|
||||
interface AccordionItem {
|
||||
title: string;
|
||||
content: string;
|
||||
isOpen?: boolean;
|
||||
}
|
||||
|
||||
interface AccordionProps {
|
||||
items?: AccordionItem[];
|
||||
style?: CSSProperties;
|
||||
headerBg?: string;
|
||||
headerColor?: string;
|
||||
contentBg?: string;
|
||||
borderColor?: string;
|
||||
anchorId?: string;
|
||||
animation?: string;
|
||||
animationDelay?: string;
|
||||
hideOnDesktop?: boolean;
|
||||
hideOnTablet?: boolean;
|
||||
hideOnMobile?: boolean;
|
||||
}
|
||||
|
||||
const defaultItems: AccordionItem[] = [
|
||||
{ title: 'What is this product?', content: 'Our product is a powerful yet easy-to-use tool designed to help you build beautiful websites without writing a single line of code.', isOpen: true },
|
||||
{ title: 'How do I get started?', content: 'Simply sign up for a free account, choose a template, and start customizing. Our drag-and-drop editor makes it easy to create professional pages in minutes.', isOpen: false },
|
||||
{ title: 'Is there a free plan?', content: 'Yes! We offer a generous free tier that includes all core features. Upgrade anytime to unlock advanced capabilities like custom domains and analytics.', isOpen: false },
|
||||
];
|
||||
|
||||
export const Accordion: UserComponent<AccordionProps> = ({
|
||||
items = defaultItems,
|
||||
style = {},
|
||||
headerBg = '#f8fafc',
|
||||
headerColor = '#18181b',
|
||||
contentBg = '#ffffff',
|
||||
borderColor = '#e2e8f0',
|
||||
anchorId,
|
||||
}) => {
|
||||
const {
|
||||
connectors: { connect, drag },
|
||||
selected,
|
||||
} = useNode((node) => ({
|
||||
selected: node.events.selected,
|
||||
}));
|
||||
|
||||
const [openIndexes, setOpenIndexes] = useState<Set<number>>(() => {
|
||||
const initial = new Set<number>();
|
||||
items.forEach((item, i) => { if (item.isOpen) initial.add(i); });
|
||||
return initial;
|
||||
});
|
||||
|
||||
const toggle = (index: number) => {
|
||||
setOpenIndexes((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(index)) next.delete(index);
|
||||
else next.add(index);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<section
|
||||
ref={(ref: HTMLElement | null): void => { if (ref) connect(drag(ref)); }}
|
||||
id={anchorId || undefined}
|
||||
style={{
|
||||
padding: '60px 20px',
|
||||
backgroundColor: '#ffffff',
|
||||
outline: selected ? '2px solid #3b82f6' : 'none',
|
||||
...style,
|
||||
}}
|
||||
>
|
||||
<div style={{ maxWidth: '800px', margin: '0 auto', display: 'flex', flexDirection: 'column', gap: '0px' }}>
|
||||
{items.map((item, i) => {
|
||||
const isOpen = openIndexes.has(i);
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
style={{
|
||||
border: `1px solid ${borderColor}`,
|
||||
borderBottom: i === items.length - 1 ? `1px solid ${borderColor}` : 'none',
|
||||
...(i === 0 ? { borderTopLeftRadius: '8px', borderTopRightRadius: '8px' } : {}),
|
||||
...(i === items.length - 1 ? { borderBottomLeftRadius: '8px', borderBottomRightRadius: '8px', borderBottom: `1px solid ${borderColor}` } : {}),
|
||||
}}
|
||||
>
|
||||
<div
|
||||
onClick={() => toggle(i)}
|
||||
style={{
|
||||
padding: '16px 20px',
|
||||
backgroundColor: headerBg,
|
||||
color: headerColor,
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
fontWeight: '600',
|
||||
fontSize: '16px',
|
||||
userSelect: 'none',
|
||||
...(i === 0 ? { borderTopLeftRadius: '7px', borderTopRightRadius: '7px' } : {}),
|
||||
...(i === items.length - 1 && !isOpen ? { borderBottomLeftRadius: '7px', borderBottomRightRadius: '7px' } : {}),
|
||||
}}
|
||||
>
|
||||
<span>{item.title}</span>
|
||||
<span style={{ fontSize: '12px', transition: 'transform 0.2s', transform: isOpen ? 'rotate(180deg)' : 'rotate(0deg)' }}>▼</span>
|
||||
</div>
|
||||
{isOpen && (
|
||||
<div
|
||||
style={{
|
||||
padding: '16px 20px',
|
||||
backgroundColor: contentBg,
|
||||
color: '#4b5563',
|
||||
fontSize: '14px',
|
||||
lineHeight: '1.6',
|
||||
borderTop: `1px solid ${borderColor}`,
|
||||
...(i === items.length - 1 ? { borderBottomLeftRadius: '7px', borderBottomRightRadius: '7px' } : {}),
|
||||
}}
|
||||
>
|
||||
{item.content}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
/* ---------- Craft config ---------- */
|
||||
|
||||
Accordion.craft = {
|
||||
displayName: 'Accordion',
|
||||
props: {
|
||||
items: defaultItems,
|
||||
style: { backgroundColor: '#ffffff' },
|
||||
headerBg: '#f8fafc',
|
||||
headerColor: '#18181b',
|
||||
contentBg: '#ffffff',
|
||||
borderColor: '#e2e8f0',
|
||||
anchorId: '',
|
||||
animation: '',
|
||||
animationDelay: '',
|
||||
hideOnDesktop: false,
|
||||
hideOnTablet: false,
|
||||
hideOnMobile: false,
|
||||
},
|
||||
rules: {
|
||||
canDrag: () => true,
|
||||
canMoveIn: () => false,
|
||||
canMoveOut: () => true,
|
||||
},
|
||||
};
|
||||
|
||||
/* ---------- HTML export ---------- */
|
||||
|
||||
(Accordion as any).toHtml = (props: AccordionProps, _childrenHtml: string) => {
|
||||
const sectionStyle = cssPropsToString({
|
||||
padding: '60px 20px',
|
||||
...props.style,
|
||||
});
|
||||
const idAttr = props.anchorId ? ` id="${escapeAttr(props.anchorId)}"` : '';
|
||||
// Sanitized -- raw string-interpolation sinks in the <details>/<summary>
|
||||
// style attributes below.
|
||||
const headerBg = cssValue(props.headerBg) || '#f8fafc';
|
||||
const headerColor = cssValue(props.headerColor) || '#18181b';
|
||||
const contentBg = cssValue(props.contentBg) || '#ffffff';
|
||||
const borderColor = cssValue(props.borderColor) || '#e2e8f0';
|
||||
const items = props.items || defaultItems;
|
||||
|
||||
const panels = items.map((item, i) => {
|
||||
const openAttr = item.isOpen ? ' open' : '';
|
||||
const topRadius = i === 0 ? 'border-top-left-radius:8px;border-top-right-radius:8px;' : '';
|
||||
const bottomRadius = i === items.length - 1 ? 'border-bottom-left-radius:8px;border-bottom-right-radius:8px;' : '';
|
||||
const borderBottom = i === items.length - 1 ? `border:1px solid ${borderColor};` : `border:1px solid ${borderColor};border-bottom:none;`;
|
||||
return `<details${openAttr} style="${borderBottom}${topRadius}${bottomRadius}">
|
||||
<summary style="padding:16px 20px;background-color:${headerBg};color:${headerColor};cursor:pointer;font-weight:600;font-size:16px;list-style:none;display:flex;justify-content:space-between;align-items:center">
|
||||
${escapeHtml(item.title)}
|
||||
</summary>
|
||||
<div style="padding:16px 20px;background-color:${contentBg};color:#4b5563;font-size:14px;line-height:1.6;border-top:1px solid ${borderColor}">
|
||||
${escapeHtml(item.content)}
|
||||
</div>
|
||||
</details>`;
|
||||
}).join('\n ');
|
||||
|
||||
return {
|
||||
html: `<section${idAttr}${sectionStyle ? ` style="${sectionStyle}"` : ''}>
|
||||
<div style="max-width:800px;margin:0 auto;display:flex;flex-direction:column">
|
||||
${panels}
|
||||
</div>
|
||||
<style>details summary::-webkit-details-marker{display:none}details summary::marker{display:none}</style>
|
||||
</section>`,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,37 @@
|
||||
import { describe, test, expect } from 'vitest';
|
||||
import { CTASection } from './CTASection';
|
||||
|
||||
const toHtml = (CTASection as any).toHtml;
|
||||
|
||||
describe('CTASection.toHtml basic export', () => {
|
||||
test('renders heading, description, and CTA buttons', () => {
|
||||
const { html } = toHtml({ heading: 'Hi', description: 'Sub', ctas: [{ text: 'Go', href: '#', variant: 'primary' }] }, '');
|
||||
expect(html).toContain('Hi');
|
||||
expect(html).toContain('Sub');
|
||||
expect(html).toContain('Go');
|
||||
});
|
||||
|
||||
test('box-model style props (margin/padding/border/boxShadow/opacity) flow through to the section style=""', () => {
|
||||
const { html } = toHtml({
|
||||
heading: 'Hi',
|
||||
description: 'Sub',
|
||||
style: { marginTop: '10px', paddingLeft: '5px', border: '1px solid #000', boxShadow: '0 1px 2px rgba(0,0,0,0.1)', opacity: '0.5' },
|
||||
}, '');
|
||||
expect(html).toContain('margin-top:10px');
|
||||
expect(html).toContain('padding-left:5px');
|
||||
expect(html).toContain('border:1px solid #000');
|
||||
expect(html).toContain('box-shadow:0 1px 2px rgba(0,0,0,0.1)');
|
||||
expect(html).toContain('opacity:0.5');
|
||||
});
|
||||
});
|
||||
|
||||
describe('CTASection.craft.props includes the box-model/animation/visibility rollout props', () => {
|
||||
test('animation, animationDelay, and all 3 hideOn* flags are declared (blank/false defaults)', () => {
|
||||
const props = (CTASection as any).craft.props;
|
||||
expect(props).toHaveProperty('animation', '');
|
||||
expect(props).toHaveProperty('animationDelay', '');
|
||||
expect(props).toHaveProperty('hideOnDesktop', false);
|
||||
expect(props).toHaveProperty('hideOnTablet', false);
|
||||
expect(props).toHaveProperty('hideOnMobile', false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,125 @@
|
||||
import React, { CSSProperties } from 'react';
|
||||
import { useNode, UserComponent } from '@craftjs/core';
|
||||
import { cssPropsToString } from '../../utils/style-helpers';
|
||||
import { CtaButton, normalizeCtas, ctaInlineStyle, ctasToHtml } from './_cta-helpers';
|
||||
import { escapeHtml, escapeAttr } from '../../utils/escape';
|
||||
|
||||
interface CTASectionProps {
|
||||
heading?: string;
|
||||
description?: string;
|
||||
ctas?: CtaButton[];
|
||||
/** Legacy props kept for backward compat with saved projects. */
|
||||
buttonText?: string;
|
||||
buttonHref?: string;
|
||||
gradient?: string;
|
||||
anchorId?: string;
|
||||
style?: CSSProperties;
|
||||
animation?: string;
|
||||
animationDelay?: string;
|
||||
hideOnDesktop?: boolean;
|
||||
hideOnTablet?: boolean;
|
||||
hideOnMobile?: boolean;
|
||||
}
|
||||
|
||||
const defaultGradient = 'linear-gradient(135deg, #2563eb 0%, #7c3aed 100%)';
|
||||
|
||||
export const CTASection: UserComponent<CTASectionProps> = ({
|
||||
heading = 'Ready to Get Started?',
|
||||
description = 'Join thousands of satisfied users and start building your dream website today.',
|
||||
ctas,
|
||||
buttonText,
|
||||
buttonHref,
|
||||
gradient = defaultGradient,
|
||||
anchorId,
|
||||
style = {},
|
||||
}) => {
|
||||
const {
|
||||
connectors: { connect, drag },
|
||||
selected,
|
||||
} = useNode((node) => ({
|
||||
selected: node.events.selected,
|
||||
}));
|
||||
|
||||
const effectiveCtas = normalizeCtas({ ctas, buttonText, buttonHref });
|
||||
const ctaDefaults = { primaryBg: '#ffffff', primaryText: '#18181b', outlineText: '#ffffff' };
|
||||
|
||||
return (
|
||||
<section
|
||||
ref={(ref: HTMLElement | null): void => { if (ref) connect(drag(ref)); }}
|
||||
id={anchorId || undefined}
|
||||
style={{
|
||||
background: gradient,
|
||||
padding: '80px 20px',
|
||||
textAlign: 'center',
|
||||
outline: selected ? '2px solid #3b82f6' : 'none',
|
||||
...style,
|
||||
}}
|
||||
>
|
||||
<div style={{ maxWidth: '700px', margin: '0 auto' }}>
|
||||
<h2 style={{ fontSize: '36px', fontWeight: '700', color: '#ffffff', marginBottom: '12px' }}>
|
||||
{heading}
|
||||
</h2>
|
||||
<p style={{ fontSize: '18px', color: 'rgba(255,255,255,0.85)', marginBottom: '28px', lineHeight: '1.6' }}>
|
||||
{description}
|
||||
</p>
|
||||
<div style={{ display: 'flex', gap: '12px', justifyContent: 'center', flexWrap: 'wrap' }}>
|
||||
{effectiveCtas.map((cta, i) => (
|
||||
<a key={i} href={cta.href || '#'} onClick={(e) => e.preventDefault()}
|
||||
style={ctaInlineStyle(cta, ctaDefaults)}>
|
||||
{cta.text}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
/* ---------- Craft config ---------- */
|
||||
|
||||
CTASection.craft = {
|
||||
displayName: 'CTA Section',
|
||||
props: {
|
||||
heading: 'Ready to Get Started?',
|
||||
description: 'Join thousands of satisfied users and start building your dream website today.',
|
||||
ctas: [
|
||||
{ text: 'Start Free Trial', href: '#', variant: 'primary' },
|
||||
] as CtaButton[],
|
||||
gradient: defaultGradient,
|
||||
anchorId: '',
|
||||
style: {},
|
||||
animation: '',
|
||||
animationDelay: '',
|
||||
hideOnDesktop: false,
|
||||
hideOnTablet: false,
|
||||
hideOnMobile: false,
|
||||
},
|
||||
rules: {
|
||||
canDrag: () => true,
|
||||
canMoveIn: () => false,
|
||||
canMoveOut: () => true,
|
||||
},
|
||||
};
|
||||
|
||||
/* ---------- HTML export ---------- */
|
||||
|
||||
(CTASection as any).toHtml = (props: CTASectionProps, _childrenHtml: string) => {
|
||||
const sectionStyle = cssPropsToString({
|
||||
background: props.gradient || defaultGradient,
|
||||
padding: '80px 20px',
|
||||
textAlign: 'center',
|
||||
...props.style,
|
||||
});
|
||||
const ctas = normalizeCtas(props);
|
||||
const buttonsHtml = ctasToHtml(ctas, { primaryBg: '#ffffff', primaryText: '#18181b', outlineText: '#ffffff' });
|
||||
const idAttr = props.anchorId ? ` id="${escapeAttr(props.anchorId)}"` : '';
|
||||
return {
|
||||
html: `<section${idAttr}${sectionStyle ? ` style="${sectionStyle}"` : ''}>
|
||||
<div style="max-width:700px;margin:0 auto">
|
||||
<h2 style="font-size:36px;font-weight:700;color:#ffffff;margin-bottom:12px">${escapeHtml(props.heading || '')}</h2>
|
||||
<p style="font-size:18px;color:rgba(255,255,255,0.85);margin-bottom:28px;line-height:1.6">${escapeHtml(props.description || '')}</p>
|
||||
<div style="display:flex;gap:12px;justify-content:center;flex-wrap:wrap">${buttonsHtml}</div>
|
||||
</div>
|
||||
</section>`,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,37 @@
|
||||
import { describe, test, expect } from 'vitest';
|
||||
import { CallToAction } from './CallToAction';
|
||||
|
||||
const toHtml = (CallToAction as any).toHtml;
|
||||
|
||||
describe('CallToAction.toHtml basic export', () => {
|
||||
test('renders heading, description, and CTA buttons', () => {
|
||||
const { html } = toHtml({ heading: 'Hi', description: 'Sub', ctas: [{ text: 'Go', href: '#', variant: 'primary' }] }, '');
|
||||
expect(html).toContain('Hi');
|
||||
expect(html).toContain('Sub');
|
||||
expect(html).toContain('Go');
|
||||
});
|
||||
|
||||
test('box-model style props (margin/padding/border/boxShadow/opacity) flow through to the section style=""', () => {
|
||||
const { html } = toHtml({
|
||||
heading: 'Hi',
|
||||
description: 'Sub',
|
||||
style: { marginBottom: '12px', paddingRight: '6px', border: '2px dashed #333', boxShadow: '0 4px 8px rgba(0,0,0,0.12)', opacity: '0.75' },
|
||||
}, '');
|
||||
expect(html).toContain('margin-bottom:12px');
|
||||
expect(html).toContain('padding-right:6px');
|
||||
expect(html).toContain('border:2px dashed #333');
|
||||
expect(html).toContain('box-shadow:0 4px 8px rgba(0,0,0,0.12)');
|
||||
expect(html).toContain('opacity:0.75');
|
||||
});
|
||||
});
|
||||
|
||||
describe('CallToAction.craft.props includes the box-model/animation/visibility rollout props', () => {
|
||||
test('animation, animationDelay, and all 3 hideOn* flags are declared (blank/false defaults)', () => {
|
||||
const props = (CallToAction as any).craft.props;
|
||||
expect(props).toHaveProperty('animation', '');
|
||||
expect(props).toHaveProperty('animationDelay', '');
|
||||
expect(props).toHaveProperty('hideOnDesktop', false);
|
||||
expect(props).toHaveProperty('hideOnTablet', false);
|
||||
expect(props).toHaveProperty('hideOnMobile', false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,208 @@
|
||||
import React, { CSSProperties } from 'react';
|
||||
import { useNode, UserComponent } from '@craftjs/core';
|
||||
import { cssPropsToString } from '../../utils/style-helpers';
|
||||
import { CtaButton, normalizeCtas, ctaInlineStyle, ctasToHtml } from './_cta-helpers';
|
||||
import { escapeHtml, escapeAttr, cssValue } from '../../utils/escape';
|
||||
|
||||
interface CallToActionProps {
|
||||
heading?: string;
|
||||
description?: string;
|
||||
ctas?: CtaButton[];
|
||||
/** Legacy props kept for backward compat with saved projects. */
|
||||
buttonText?: string;
|
||||
buttonHref?: string;
|
||||
secondaryButtonText?: string;
|
||||
secondaryButtonHref?: string;
|
||||
bgType?: 'color' | 'gradient' | 'image';
|
||||
bgValue?: string;
|
||||
overlayColor?: string;
|
||||
overlayOpacity?: number;
|
||||
textColor?: string;
|
||||
buttonColor?: string;
|
||||
anchorId?: string;
|
||||
style?: CSSProperties;
|
||||
animation?: string;
|
||||
animationDelay?: string;
|
||||
hideOnDesktop?: boolean;
|
||||
hideOnTablet?: boolean;
|
||||
hideOnMobile?: boolean;
|
||||
}
|
||||
|
||||
const defaultGradient = 'linear-gradient(135deg, #2563eb 0%, #7c3aed 100%)';
|
||||
|
||||
export const CallToAction: UserComponent<CallToActionProps> = ({
|
||||
heading = 'Ready to Get Started?',
|
||||
description = 'Join thousands of satisfied users and start building your dream website today.',
|
||||
ctas,
|
||||
buttonText,
|
||||
buttonHref,
|
||||
secondaryButtonText,
|
||||
secondaryButtonHref,
|
||||
bgType = 'gradient',
|
||||
bgValue = defaultGradient,
|
||||
overlayColor = '#000000',
|
||||
overlayOpacity = 0,
|
||||
textColor = '#ffffff',
|
||||
buttonColor = '#ffffff',
|
||||
anchorId,
|
||||
style = {},
|
||||
}) => {
|
||||
const {
|
||||
connectors: { connect, drag },
|
||||
selected,
|
||||
} = useNode((node) => ({
|
||||
selected: node.events.selected,
|
||||
}));
|
||||
|
||||
const bgStyle: CSSProperties = {};
|
||||
if (bgType === 'color') {
|
||||
bgStyle.backgroundColor = bgValue;
|
||||
} else if (bgType === 'gradient') {
|
||||
bgStyle.background = bgValue;
|
||||
} else if (bgType === 'image') {
|
||||
bgStyle.backgroundImage = `url(${bgValue})`;
|
||||
bgStyle.backgroundSize = 'cover';
|
||||
bgStyle.backgroundPosition = 'center';
|
||||
}
|
||||
|
||||
const isButtonDark = buttonColor === '#ffffff' || buttonColor === '#f8fafc';
|
||||
const buttonTextColor = isButtonDark ? '#18181b' : '#ffffff';
|
||||
|
||||
const effectiveCtas = normalizeCtas({ ctas, buttonText, buttonHref, secondaryButtonText, secondaryButtonHref });
|
||||
const ctaDefaults = { primaryBg: buttonColor, primaryText: buttonTextColor, outlineText: textColor };
|
||||
|
||||
return (
|
||||
<section
|
||||
ref={(ref: HTMLElement | null): void => { if (ref) connect(drag(ref)); }}
|
||||
id={anchorId || undefined}
|
||||
style={{
|
||||
position: 'relative',
|
||||
padding: '80px 20px',
|
||||
textAlign: 'center',
|
||||
outline: selected ? '2px solid #3b82f6' : 'none',
|
||||
...bgStyle,
|
||||
...style,
|
||||
}}
|
||||
>
|
||||
{/* Overlay */}
|
||||
{bgType === 'image' && overlayOpacity > 0 && (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
backgroundColor: overlayColor,
|
||||
opacity: overlayOpacity / 100,
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div style={{ maxWidth: '700px', margin: '0 auto', position: 'relative', zIndex: 1 }}>
|
||||
<h2 style={{ fontSize: '36px', fontWeight: '700', color: textColor, marginBottom: '12px' }}>
|
||||
{heading}
|
||||
</h2>
|
||||
<p style={{ fontSize: '18px', color: textColor, opacity: 0.85, marginBottom: '28px', lineHeight: '1.6' }}>
|
||||
{description}
|
||||
</p>
|
||||
<div style={{ display: 'flex', gap: '12px', justifyContent: 'center', flexWrap: 'wrap' }}>
|
||||
{effectiveCtas.map((cta, i) => (
|
||||
<a key={i} href={cta.href || '#'} onClick={(e) => e.preventDefault()}
|
||||
style={ctaInlineStyle(cta, ctaDefaults)}>
|
||||
{cta.text}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
/* ---------- Craft config ---------- */
|
||||
|
||||
CallToAction.craft = {
|
||||
displayName: 'Call to Action',
|
||||
props: {
|
||||
heading: 'Ready to Get Started?',
|
||||
description: 'Join thousands of satisfied users and start building your dream website today.',
|
||||
ctas: [
|
||||
{ text: 'Get Started', href: '#', variant: 'primary' },
|
||||
{ text: 'Learn More', href: '#', variant: 'outline' },
|
||||
] as CtaButton[],
|
||||
anchorId: '',
|
||||
bgType: 'gradient',
|
||||
bgValue: defaultGradient,
|
||||
overlayColor: '#000000',
|
||||
overlayOpacity: 0,
|
||||
textColor: '#ffffff',
|
||||
buttonColor: '#ffffff',
|
||||
style: {},
|
||||
animation: '',
|
||||
animationDelay: '',
|
||||
hideOnDesktop: false,
|
||||
hideOnTablet: false,
|
||||
hideOnMobile: false,
|
||||
},
|
||||
rules: {
|
||||
canDrag: () => true,
|
||||
canMoveIn: () => false,
|
||||
canMoveOut: () => true,
|
||||
},
|
||||
};
|
||||
|
||||
/* ---------- HTML export ---------- */
|
||||
|
||||
(CallToAction as any).toHtml = (props: CallToActionProps, _childrenHtml: string) => {
|
||||
const bgType = props.bgType || 'gradient';
|
||||
const bgValue = props.bgValue || defaultGradient;
|
||||
// Sanitized -- raw string-interpolation sink in the heading/description
|
||||
// style attributes below.
|
||||
const textColor = cssValue(props.textColor) || '#ffffff';
|
||||
const buttonColor = props.buttonColor || '#ffffff';
|
||||
const isButtonDark = buttonColor === '#ffffff' || buttonColor === '#f8fafc';
|
||||
const buttonTextColor = isButtonDark ? '#18181b' : '#ffffff';
|
||||
|
||||
const sectionCss: CSSProperties = {
|
||||
position: 'relative',
|
||||
padding: '80px 20px',
|
||||
textAlign: 'center',
|
||||
...props.style,
|
||||
};
|
||||
|
||||
if (bgType === 'color') {
|
||||
sectionCss.backgroundColor = bgValue;
|
||||
} else if (bgType === 'gradient') {
|
||||
sectionCss.background = bgValue;
|
||||
} else if (bgType === 'image') {
|
||||
sectionCss.backgroundImage = `url(${bgValue})`;
|
||||
sectionCss.backgroundSize = 'cover';
|
||||
sectionCss.backgroundPosition = 'center';
|
||||
}
|
||||
|
||||
const sectionStyle = cssPropsToString(sectionCss);
|
||||
|
||||
let overlayHtml = '';
|
||||
if (bgType === 'image' && (props.overlayOpacity || 0) > 0) {
|
||||
const overlayStyle = cssPropsToString({
|
||||
position: 'absolute',
|
||||
inset: '0',
|
||||
backgroundColor: props.overlayColor || '#000000',
|
||||
opacity: String((props.overlayOpacity || 0) / 100) as any,
|
||||
pointerEvents: 'none',
|
||||
});
|
||||
overlayHtml = `<div${overlayStyle ? ` style="${overlayStyle}"` : ''}></div>`;
|
||||
}
|
||||
|
||||
const ctas = normalizeCtas(props);
|
||||
const buttonsHtml = ctasToHtml(ctas, { primaryBg: buttonColor, primaryText: buttonTextColor, outlineText: textColor });
|
||||
const idAttr = props.anchorId ? ` id="${escapeAttr(props.anchorId)}"` : '';
|
||||
|
||||
return {
|
||||
html: `<section${idAttr}${sectionStyle ? ` style="${sectionStyle}"` : ''}>
|
||||
${overlayHtml}<div style="max-width:700px;margin:0 auto;position:relative;z-index:1">
|
||||
<h2 style="font-size:36px;font-weight:700;color:${textColor};margin-bottom:12px">${escapeHtml(props.heading || '')}</h2>
|
||||
<p style="font-size:18px;color:${textColor};opacity:0.85;margin-bottom:28px;line-height:1.6">${escapeHtml(props.description || '')}</p>
|
||||
<div style="display:flex;gap:12px;justify-content:center;flex-wrap:wrap">${buttonsHtml}</div>
|
||||
</div>
|
||||
</section>`,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,141 @@
|
||||
import { describe, test, expect } from 'vitest';
|
||||
import { ContentSlider } from './ContentSlider';
|
||||
|
||||
const toHtml = (ContentSlider as any).toHtml;
|
||||
|
||||
const slides = [
|
||||
{ type: 'image' as const, heading: 'One' },
|
||||
{ type: 'image' as const, heading: 'Two' },
|
||||
{ type: 'image' as const, heading: 'Three' },
|
||||
];
|
||||
|
||||
describe('ContentSlider.toHtml accessibility (F1.1)', () => {
|
||||
test('prev/next arrows get aria-labels and are real buttons', () => {
|
||||
const { html } = toHtml({ slides, showArrows: true }, '');
|
||||
expect(html).toMatch(/<button[^>]*aria-label="Previous slide"/);
|
||||
expect(html).toMatch(/<button[^>]*aria-label="Next slide"/);
|
||||
});
|
||||
|
||||
test('dot buttons get "Go to slide N" aria-labels', () => {
|
||||
const { html } = toHtml({ slides, showDots: true }, '');
|
||||
expect(html).toMatch(/<button[^>]*aria-label="Go to slide 1"/);
|
||||
expect(html).toMatch(/<button[^>]*aria-label="Go to slide 2"/);
|
||||
expect(html).toMatch(/<button[^>]*aria-label="Go to slide 3"/);
|
||||
});
|
||||
|
||||
test('slides are wrapped in an aria-live region', () => {
|
||||
const { html } = toHtml({ slides }, '');
|
||||
expect(html).toMatch(/aria-live="(polite|off)"/);
|
||||
});
|
||||
|
||||
test('decorative chevron icons in arrows are aria-hidden', () => {
|
||||
const { html } = toHtml({ slides, showArrows: true }, '');
|
||||
expect(html).toMatch(/<i class="fa fa-chevron-left" aria-hidden="true"><\/i>/);
|
||||
expect(html).toMatch(/<i class="fa fa-chevron-right" aria-hidden="true"><\/i>/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ContentSlider.toHtml deterministic + unique scope ids (thread node id)', () => {
|
||||
test('same node id -> identical output across calls (deterministic, no Math.random)', () => {
|
||||
const { html: html1 } = toHtml({ slides }, '', 'node-cs1');
|
||||
const { html: html2 } = toHtml({ slides }, '', 'node-cs1');
|
||||
expect(html1).toBe(html2);
|
||||
});
|
||||
|
||||
test('different node ids -> different, non-colliding scope ids (identical slides, no collision)', () => {
|
||||
const { html: html1 } = toHtml({ slides }, '', 'node-cs1');
|
||||
const { html: html2 } = toHtml({ slides }, '', 'node-cs2');
|
||||
const id1 = html1.match(/<section id="([^"]+)"/)![1];
|
||||
const id2 = html2.match(/<section id="([^"]+)"/)![1];
|
||||
expect(id1).not.toBe(id2);
|
||||
});
|
||||
|
||||
test('no nodeId (legacy 2-arg call): still deterministic across repeated calls, not random', () => {
|
||||
const { html: html1 } = toHtml({ slides }, '');
|
||||
const { html: html2 } = toHtml({ slides }, '');
|
||||
expect(html1).toBe(html2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ContentSlider.toHtml autoplay silences aria-live and is pausable (F-export review Minor)', () => {
|
||||
test('aria-live is "off" while autoplay is running, to avoid announcing every auto-rotation', () => {
|
||||
const { html } = toHtml({ slides, autoplay: true }, '');
|
||||
expect(html).toContain('aria-live="off"');
|
||||
});
|
||||
|
||||
test('aria-live stays "polite" when autoplay is disabled', () => {
|
||||
const { html } = toHtml({ slides, autoplay: false }, '');
|
||||
expect(html).toContain('aria-live="polite"');
|
||||
});
|
||||
|
||||
test('manual navigation (next/prev/dot) marks the live region "polite"', () => {
|
||||
const { html } = toHtml({ slides, autoplay: true }, '');
|
||||
expect(html).toMatch(/setAttribute\(["']aria-live["'],\s*["']polite["']\)/);
|
||||
});
|
||||
|
||||
test('autoplay pauses on hover and resumes on mouse leave', () => {
|
||||
const { html } = toHtml({ slides, autoplay: true }, '');
|
||||
expect(html).toMatch(/addEventListener\(["']mouseenter["']/);
|
||||
expect(html).toMatch(/addEventListener\(["']mouseleave["']/);
|
||||
});
|
||||
|
||||
test('autoplay pauses when the tab is hidden (visibilitychange) and the interval is clearable', () => {
|
||||
const { html } = toHtml({ slides, autoplay: true }, '');
|
||||
expect(html).toMatch(/visibilitychange/);
|
||||
expect(html).toMatch(/clearInterval\(/);
|
||||
});
|
||||
|
||||
test('no autoplay: no setInterval/hover/visibility wiring at all', () => {
|
||||
const { html } = toHtml({ slides, autoplay: false }, '');
|
||||
expect(html).not.toMatch(/setInterval\(/);
|
||||
expect(html).not.toMatch(/visibilitychange/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ContentSlider.toHtml renders slide.imageSrc as a background-image (INT)', () => {
|
||||
test('a slide with imageSrc set exports a background-image referencing it', () => {
|
||||
const slidesWithImage = [
|
||||
{ type: 'image' as const, imageSrc: 'https://example.com/photo.jpg', heading: 'One' },
|
||||
];
|
||||
const { html } = toHtml({ slides: slidesWithImage }, '');
|
||||
expect(html).toContain("background-image:url('https://example.com/photo.jpg')");
|
||||
});
|
||||
|
||||
test('a slide with no imageSrc falls back to bgColor (no broken/empty background-image url)', () => {
|
||||
const slidesNoImage = [
|
||||
{ type: 'image' as const, imageSrc: '', heading: 'One', bgColor: '#123456' },
|
||||
];
|
||||
const { html } = toHtml({ slides: slidesNoImage }, '');
|
||||
expect(html).not.toContain('background-image:url(');
|
||||
expect(html).toContain('background-color:#123456');
|
||||
});
|
||||
|
||||
test('a slide with a data:image/svg+xml imageSrc exports a non-empty background-image url (safeImageUrl, not safeUrl)', () => {
|
||||
const svgDataUri = 'data:image/svg+xml,%3Csvg%2F%3E';
|
||||
const slidesWithSvg = [
|
||||
{ type: 'image' as const, imageSrc: svgDataUri, heading: 'One' },
|
||||
];
|
||||
const { html } = toHtml({ slides: slidesWithSvg }, '');
|
||||
expect(html).toContain(`background-image:url('${svgDataUri}')`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ContentSlider.toHtml interval is NOT runtime-type-checked -- must be coerced before it reaches the inline <script> numeric context', () => {
|
||||
test('a malicious interval string cannot inject arbitrary JS into the autoplay setInterval call', () => {
|
||||
const malicious = '5000);alert(document.domain);//';
|
||||
const { html } = toHtml({ slides, autoplay: true, interval: malicious }, '');
|
||||
expect(html).not.toContain('alert(document.domain)');
|
||||
// the setInterval call must still be well-formed with a plain numeral delay
|
||||
expect(html).toMatch(/setInterval\(function\(\)\{show\(current\+1\);\},\d+\);/);
|
||||
});
|
||||
|
||||
test('a non-numeric interval falls back to a safe default delay', () => {
|
||||
const { html } = toHtml({ slides, autoplay: true, interval: 'not-a-number' }, '');
|
||||
expect(html).toMatch(/setInterval\(function\(\)\{show\(current\+1\);\},5000\);/);
|
||||
});
|
||||
|
||||
test('a normal numeric interval still renders as the exact configured delay', () => {
|
||||
const { html } = toHtml({ slides, autoplay: true, interval: 3000 }, '');
|
||||
expect(html).toMatch(/setInterval\(function\(\)\{show\(current\+1\);\},3000\);/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,371 @@
|
||||
import React, { CSSProperties, useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { useNode, UserComponent } from '@craftjs/core';
|
||||
import { cssPropsToString } from '../../utils/style-helpers';
|
||||
import { escapeHtml, escapeAttr, safeUrl, safeImageUrl, scopeId, cssValue } from '../../utils/escape';
|
||||
|
||||
interface Slide {
|
||||
type: 'image' | 'content';
|
||||
imageSrc?: string;
|
||||
heading?: string;
|
||||
text?: string;
|
||||
buttonText?: string;
|
||||
buttonHref?: string;
|
||||
bgColor?: string;
|
||||
}
|
||||
|
||||
interface ContentSliderProps {
|
||||
slides?: Slide[];
|
||||
autoplay?: boolean;
|
||||
interval?: number;
|
||||
showDots?: boolean;
|
||||
showArrows?: boolean;
|
||||
height?: string;
|
||||
style?: CSSProperties;
|
||||
}
|
||||
|
||||
const defaultSlides: Slide[] = [
|
||||
{
|
||||
type: 'image',
|
||||
imageSrc: '',
|
||||
heading: 'First Slide',
|
||||
text: 'Welcome to our showcase',
|
||||
buttonText: 'Learn More',
|
||||
buttonHref: '#',
|
||||
bgColor: 'linear-gradient(135deg, #3b82f6 0%, #8b5cf6 100%)',
|
||||
},
|
||||
{
|
||||
type: 'image',
|
||||
imageSrc: '',
|
||||
heading: 'Second Slide',
|
||||
text: 'Discover something amazing',
|
||||
buttonText: 'Get Started',
|
||||
buttonHref: '#',
|
||||
bgColor: 'linear-gradient(135deg, #10b981 0%, #059669 100%)',
|
||||
},
|
||||
{
|
||||
type: 'image',
|
||||
imageSrc: '',
|
||||
heading: 'Third Slide',
|
||||
text: 'Build your future today',
|
||||
buttonText: 'Contact Us',
|
||||
buttonHref: '#',
|
||||
bgColor: 'linear-gradient(135deg, #f59e0b 0%, #ef4444 100%)',
|
||||
},
|
||||
];
|
||||
|
||||
export const ContentSlider: UserComponent<ContentSliderProps> = ({
|
||||
slides = defaultSlides,
|
||||
autoplay = true,
|
||||
interval = 5000,
|
||||
showDots = true,
|
||||
showArrows = true,
|
||||
height = '400px',
|
||||
style = {},
|
||||
}) => {
|
||||
const {
|
||||
connectors: { connect, drag },
|
||||
selected,
|
||||
} = useNode((node) => ({
|
||||
selected: node.events.selected,
|
||||
}));
|
||||
|
||||
const [activeIndex, setActiveIndex] = useState(0);
|
||||
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const items = slides.length > 0 ? slides : defaultSlides;
|
||||
|
||||
const goTo = useCallback((index: number) => {
|
||||
setActiveIndex(((index % items.length) + items.length) % items.length);
|
||||
}, [items.length]);
|
||||
|
||||
const goNext = useCallback(() => goTo(activeIndex + 1), [activeIndex, goTo]);
|
||||
const goPrev = useCallback(() => goTo(activeIndex - 1), [activeIndex, goTo]);
|
||||
|
||||
useEffect(() => {
|
||||
if (autoplay && items.length > 1) {
|
||||
timerRef.current = setInterval(goNext, interval);
|
||||
return () => { if (timerRef.current) clearInterval(timerRef.current); };
|
||||
}
|
||||
}, [autoplay, interval, goNext, items.length]);
|
||||
|
||||
const arrowStyle: CSSProperties = {
|
||||
position: 'absolute',
|
||||
top: '50%',
|
||||
transform: 'translateY(-50%)',
|
||||
width: '40px',
|
||||
height: '40px',
|
||||
borderRadius: '50%',
|
||||
border: 'none',
|
||||
background: 'rgba(255,255,255,0.9)',
|
||||
color: '#18181b',
|
||||
fontSize: '16px',
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
zIndex: 2,
|
||||
boxShadow: '0 2px 8px rgba(0,0,0,0.15)',
|
||||
};
|
||||
|
||||
const renderSlide = (slide: Slide, i: number) => {
|
||||
const bg = slide.imageSrc
|
||||
? { backgroundImage: `url(${slide.imageSrc})`, backgroundSize: 'cover', backgroundPosition: 'center' }
|
||||
: slide.bgColor?.startsWith('linear-gradient')
|
||||
? { backgroundImage: slide.bgColor }
|
||||
: { backgroundColor: slide.bgColor || '#3b82f6' };
|
||||
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
opacity: i === activeIndex ? 1 : 0,
|
||||
transition: 'opacity 0.5s ease-in-out',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
...bg,
|
||||
}}
|
||||
>
|
||||
{(slide.heading || slide.text || slide.buttonText) && (
|
||||
<div style={{ textAlign: 'center', padding: '20px', zIndex: 1 }}>
|
||||
{slide.heading && (
|
||||
<h2 style={{ fontSize: '36px', fontWeight: '700', color: '#ffffff', marginBottom: '12px', fontFamily: 'Inter, sans-serif', textShadow: '0 2px 8px rgba(0,0,0,0.3)' }}>
|
||||
{slide.heading}
|
||||
</h2>
|
||||
)}
|
||||
{slide.text && (
|
||||
<p style={{ fontSize: '18px', color: 'rgba(255,255,255,0.9)', marginBottom: '20px', fontFamily: 'Inter, sans-serif', textShadow: '0 1px 4px rgba(0,0,0,0.3)' }}>
|
||||
{slide.text}
|
||||
</p>
|
||||
)}
|
||||
{slide.buttonText && (
|
||||
<a
|
||||
href={slide.buttonHref || '#'}
|
||||
onClick={(e) => e.preventDefault()}
|
||||
style={{
|
||||
display: 'inline-block',
|
||||
padding: '12px 28px',
|
||||
background: '#ffffff',
|
||||
color: '#18181b',
|
||||
textDecoration: 'none',
|
||||
borderRadius: '8px',
|
||||
fontWeight: '600',
|
||||
fontSize: '15px',
|
||||
fontFamily: 'Inter, sans-serif',
|
||||
}}
|
||||
>
|
||||
{slide.buttonText}
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<section
|
||||
ref={(ref: HTMLElement | null): void => { if (ref) connect(drag(ref)); }}
|
||||
style={{
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
height,
|
||||
overflow: 'hidden',
|
||||
outline: selected ? '2px solid #3b82f6' : 'none',
|
||||
...style,
|
||||
}}
|
||||
>
|
||||
{items.map((slide, i) => renderSlide(slide, i))}
|
||||
|
||||
{showArrows && items.length > 1 && (
|
||||
<>
|
||||
<button onClick={goPrev} style={{ ...arrowStyle, left: '16px' }}>
|
||||
<i className="fa fa-chevron-left" />
|
||||
</button>
|
||||
<button onClick={goNext} style={{ ...arrowStyle, right: '16px' }}>
|
||||
<i className="fa fa-chevron-right" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{showDots && items.length > 1 && (
|
||||
<div style={{ position: 'absolute', bottom: '16px', left: '50%', transform: 'translateX(-50%)', display: 'flex', gap: '8px', zIndex: 2 }}>
|
||||
{items.map((_, i) => (
|
||||
<button
|
||||
key={i}
|
||||
onClick={() => goTo(i)}
|
||||
style={{
|
||||
width: '10px',
|
||||
height: '10px',
|
||||
borderRadius: '50%',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
backgroundColor: i === activeIndex ? '#ffffff' : 'rgba(255,255,255,0.5)',
|
||||
transition: 'background-color 0.3s',
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
/* ---------- Craft config ---------- */
|
||||
|
||||
ContentSlider.craft = {
|
||||
displayName: 'Content Slider',
|
||||
props: {
|
||||
slides: defaultSlides,
|
||||
autoplay: true,
|
||||
interval: 5000,
|
||||
showDots: true,
|
||||
showArrows: true,
|
||||
height: '400px',
|
||||
style: {},
|
||||
},
|
||||
rules: {
|
||||
canDrag: () => true,
|
||||
canMoveIn: () => false,
|
||||
canMoveOut: () => true,
|
||||
},
|
||||
};
|
||||
|
||||
/* ---------- HTML export ---------- */
|
||||
|
||||
(ContentSlider as any).toHtml = (props: ContentSliderProps, _childrenHtml: string, nodeId?: string) => {
|
||||
const {
|
||||
slides = defaultSlides,
|
||||
autoplay = true,
|
||||
interval = 5000,
|
||||
showDots = true,
|
||||
showArrows = true,
|
||||
height = '400px',
|
||||
style = {},
|
||||
} = props;
|
||||
|
||||
const items = slides.length > 0 ? slides : defaultSlides;
|
||||
// Number() coercion: `interval` is declared `number` in TS but is NOT
|
||||
// type-checked at runtime -- it arrives raw via the AI `update_props`
|
||||
// path or a deserialized saved-state blob and is interpolated directly
|
||||
// into the inline <script>'s `setInterval(fn, ${interval})` call below as
|
||||
// a bare JS numeral (no quotes around it). A string like
|
||||
// `5000);alert(1);//` would previously close the setInterval() call and
|
||||
// splice arbitrary JS into the page's own <script> tag -- worse than an
|
||||
// HTML attribute breakout, since it runs unconditionally on page load.
|
||||
// Number() of anything non-numeric collapses safely to NaN, so we fall
|
||||
// back to the 5000ms default rather than ever interpolating a
|
||||
// non-numeral.
|
||||
const intervalNum = Number(interval);
|
||||
const safeInterval = Number.isFinite(intervalNum) && intervalNum > 0 ? intervalNum : 5000;
|
||||
// Deterministic AND unique id, scoped on the Craft node id, for this
|
||||
// slider's slide/dot element ids and inline-script globals -- so two
|
||||
// ContentSlider instances (e.g. both left at default slides) don't
|
||||
// collide and end up driving each other's rotation.
|
||||
const uid = scopeId(nodeId, JSON.stringify(items) + safeInterval, 'cs');
|
||||
|
||||
const sectionStyle = cssPropsToString({
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
height,
|
||||
overflow: 'hidden',
|
||||
...style,
|
||||
});
|
||||
|
||||
const slidesHtml = items.map((slide, i) => {
|
||||
const hasBgImage = slide.imageSrc;
|
||||
// Sanitized -- slide.bgColor is a per-slide raw string-interpolation
|
||||
// sink (a malicious value could break out of the style="..." attribute).
|
||||
const safeBgColor = cssValue(slide.bgColor) || '#3b82f6';
|
||||
const bgStyle = hasBgImage
|
||||
? `background-image:url('${escapeAttr(safeImageUrl(slide.imageSrc!))}');background-size:cover;background-position:center`
|
||||
: slide.bgColor?.startsWith('linear-gradient')
|
||||
? `background-image:${safeBgColor}`
|
||||
: `background-color:${safeBgColor}`;
|
||||
|
||||
const contentParts: string[] = [];
|
||||
if (slide.heading) {
|
||||
contentParts.push(`<h2 style="font-size:36px;font-weight:700;color:#ffffff;margin-bottom:12px;font-family:Inter,sans-serif;text-shadow:0 2px 8px rgba(0,0,0,0.3)">${escapeHtml(slide.heading)}</h2>`);
|
||||
}
|
||||
if (slide.text) {
|
||||
contentParts.push(`<p style="font-size:18px;color:rgba(255,255,255,0.9);margin-bottom:20px;font-family:Inter,sans-serif;text-shadow:0 1px 4px rgba(0,0,0,0.3)">${escapeHtml(slide.text)}</p>`);
|
||||
}
|
||||
if (slide.buttonText) {
|
||||
contentParts.push(`<a href="${escapeAttr(safeUrl(slide.buttonHref || '#'))}" style="display:inline-block;padding:12px 28px;background:#ffffff;color:#18181b;text-decoration:none;border-radius:8px;font-weight:600;font-size:15px;font-family:Inter,sans-serif">${escapeHtml(slide.buttonText)}</a>`);
|
||||
}
|
||||
|
||||
const innerHtml = contentParts.length > 0
|
||||
? `<div style="text-align:center;padding:20px;z-index:1">${contentParts.join('\n ')}</div>`
|
||||
: '';
|
||||
|
||||
return `<div id="${uid}_s${i}" style="position:absolute;top:0;left:0;width:100%;height:100%;opacity:${i === 0 ? 1 : 0};transition:opacity 0.5s ease-in-out;display:flex;flex-direction:column;align-items:center;justify-content:center;${bgStyle}">
|
||||
${innerHtml}
|
||||
</div>`;
|
||||
}).join('\n ');
|
||||
|
||||
const arrowsHtml = showArrows && items.length > 1
|
||||
? `<button onclick="${uid}_prev()" aria-label="Previous slide" style="position:absolute;top:50%;left:16px;transform:translateY(-50%);width:40px;height:40px;border-radius:50%;border:none;background:rgba(255,255,255,0.9);color:#18181b;font-size:16px;cursor:pointer;display:flex;align-items:center;justify-content:center;z-index:2;box-shadow:0 2px 8px rgba(0,0,0,0.15)"><i class="fa fa-chevron-left" aria-hidden="true"></i></button>
|
||||
<button onclick="${uid}_next()" aria-label="Next slide" style="position:absolute;top:50%;right:16px;transform:translateY(-50%);width:40px;height:40px;border-radius:50%;border:none;background:rgba(255,255,255,0.9);color:#18181b;font-size:16px;cursor:pointer;display:flex;align-items:center;justify-content:center;z-index:2;box-shadow:0 2px 8px rgba(0,0,0,0.15)"><i class="fa fa-chevron-right" aria-hidden="true"></i></button>`
|
||||
: '';
|
||||
|
||||
const dotsHtml = showDots && items.length > 1
|
||||
? `<div style="position:absolute;bottom:16px;left:50%;transform:translateX(-50%);display:flex;gap:8px;z-index:2">
|
||||
${items.map((_, i) => `<button onclick="${uid}_go(${i})" id="${uid}_d${i}" aria-label="Go to slide ${i + 1}" style="width:10px;height:10px;border-radius:50%;border:none;cursor:pointer;background-color:${i === 0 ? '#ffffff' : 'rgba(255,255,255,0.5)'};transition:background-color 0.3s"></button>`).join('\n ')}
|
||||
</div>`
|
||||
: '';
|
||||
|
||||
// Autoplay ticks call show() directly (internal), while manual nav goes
|
||||
// through the exposed window[...] functions -- that split lets us mark
|
||||
// the live region "polite" only on manual navigation, and keep it "off"
|
||||
// while autoplay is silently auto-rotating, so screen readers aren't
|
||||
// spammed with an announcement every `interval` ms (F-export review
|
||||
// Minor).
|
||||
const autoplayActive = autoplay && items.length > 1;
|
||||
const liveAttr = autoplayActive ? 'off' : 'polite';
|
||||
|
||||
return {
|
||||
html: `<section id="${uid}"${sectionStyle ? ` style="${sectionStyle}"` : ''}>
|
||||
<div id="${uid}_live" aria-live="${liveAttr}">
|
||||
${slidesHtml}
|
||||
</div>
|
||||
${arrowsHtml}
|
||||
${dotsHtml}
|
||||
<script>
|
||||
(function(){
|
||||
var current=0, total=${items.length}, uid="${uid}";
|
||||
var liveRegion=document.getElementById(uid+"_live");
|
||||
function markManualNav(){ if(liveRegion){ liveRegion.setAttribute("aria-live","polite"); } }
|
||||
function show(idx){
|
||||
document.getElementById(uid+"_s"+current).style.opacity="0";
|
||||
${showDots ? `document.getElementById(uid+"_d"+current).style.backgroundColor="rgba(255,255,255,0.5)";` : ''}
|
||||
current=((idx%total)+total)%total;
|
||||
document.getElementById(uid+"_s"+current).style.opacity="1";
|
||||
${showDots ? `document.getElementById(uid+"_d"+current).style.backgroundColor="#ffffff";` : ''}
|
||||
}
|
||||
window["${uid}_go"]=function(idx){ markManualNav(); show(idx); };
|
||||
window["${uid}_next"]=function(){ markManualNav(); show(current+1); };
|
||||
window["${uid}_prev"]=function(){ markManualNav(); show(current-1); };
|
||||
${autoplayActive ? `
|
||||
var timer=null;
|
||||
function start(){ if(!timer && document.visibilityState!=="hidden"){ timer=setInterval(function(){show(current+1);},${safeInterval}); } }
|
||||
function stop(){ if(timer){ clearInterval(timer); timer=null; } }
|
||||
var root=document.getElementById(uid);
|
||||
if(root){
|
||||
root.addEventListener("mouseenter", stop);
|
||||
root.addEventListener("mouseleave", start);
|
||||
}
|
||||
document.addEventListener("visibilitychange", function(){
|
||||
if(document.visibilityState==="hidden"){ stop(); } else { start(); }
|
||||
});
|
||||
start();
|
||||
` : ''}
|
||||
})();
|
||||
</script>
|
||||
</section>`,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,75 @@
|
||||
import { describe, test, expect } from 'vitest';
|
||||
import { Countdown } from './Countdown';
|
||||
|
||||
const toHtml = (Countdown as any).toHtml;
|
||||
|
||||
describe('Countdown.toHtml validates targetDate before inline-script injection (A4.2)', () => {
|
||||
test('malicious targetDate cannot break out of the new Date(...) call', () => {
|
||||
const { html } = toHtml({ targetDate: '2026-01-01");alert(1)//' }, '');
|
||||
expect(html).not.toContain('alert(');
|
||||
expect(html).not.toContain('");');
|
||||
});
|
||||
|
||||
test('valid date is JSON-encoded into the script', () => {
|
||||
const { html } = toHtml({ targetDate: '2026-01-01' }, '');
|
||||
expect(html).toContain('new Date("2026-01-01")');
|
||||
});
|
||||
|
||||
test('valid date+time is preserved', () => {
|
||||
const { html } = toHtml({ targetDate: '2026-01-01T12:30:00' }, '');
|
||||
expect(html).toContain('new Date("2026-01-01T12:30:00")');
|
||||
});
|
||||
|
||||
test('invalid/empty targetDate falls back safely (no injected literal)', () => {
|
||||
const { html } = toHtml({ targetDate: 'not-a-date' }, '');
|
||||
expect(html).not.toContain('not-a-date');
|
||||
expect(html).toMatch(/new Date\(\)\.getTime\(\)|new Date\(Date\.now\(\)\)\.getTime\(\)/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Countdown.toHtml deterministic + unique scope ids (thread node id, no Math.random)', () => {
|
||||
const props = { targetDate: '2026-01-01' };
|
||||
|
||||
test('same node id -> identical output across calls (deterministic)', () => {
|
||||
const { html: html1 } = toHtml(props, '', 'node-cd1');
|
||||
const { html: html2 } = toHtml(props, '', 'node-cd1');
|
||||
expect(html1).toBe(html2);
|
||||
});
|
||||
|
||||
test('different node ids -> different, non-colliding element ids (identical props, no collision)', () => {
|
||||
const { html: html1 } = toHtml(props, '', 'node-cd1');
|
||||
const { html: html2 } = toHtml(props, '', 'node-cd2');
|
||||
const id1 = html1.match(/id="([^"]+)_d"/)![1];
|
||||
const id2 = html2.match(/id="([^"]+)_d"/)![1];
|
||||
expect(id1).not.toBe(id2);
|
||||
});
|
||||
|
||||
test('no nodeId (legacy 2-arg call): still deterministic across repeated calls, not random', () => {
|
||||
const { html: html1 } = toHtml(props, '');
|
||||
const { html: html2 } = toHtml(props, '');
|
||||
expect(html1).toBe(html2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Countdown.toHtml script nit: ticking interval stops at zero', () => {
|
||||
test('inline script clears its own interval once the countdown reaches zero', () => {
|
||||
const { html } = toHtml({ targetDate: '2026-01-01' }, '');
|
||||
expect(html).toMatch(/clearInterval\(/);
|
||||
});
|
||||
|
||||
test('an already-expired target never schedules a running interval', () => {
|
||||
const { html } = toHtml({ targetDate: '2020-01-01' }, '');
|
||||
expect(html).toMatch(/if\s*\(\s*target\s*-\s*Date\.now\(\)\s*>\s*0\s*\)\s*\{/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Countdown.craft.props includes the box-model/animation/visibility rollout props', () => {
|
||||
test('animation, animationDelay, and all 3 hideOn* flags are declared (blank/false defaults)', () => {
|
||||
const props = (Countdown as any).craft.props;
|
||||
expect(props).toHaveProperty('animation', '');
|
||||
expect(props).toHaveProperty('animationDelay', '');
|
||||
expect(props).toHaveProperty('hideOnDesktop', false);
|
||||
expect(props).toHaveProperty('hideOnTablet', false);
|
||||
expect(props).toHaveProperty('hideOnMobile', false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,241 @@
|
||||
import React, { CSSProperties, useEffect, useState } from 'react';
|
||||
import { useNode, UserComponent } from '@craftjs/core';
|
||||
import { cssPropsToString } from '../../utils/style-helpers';
|
||||
import { escapeHtml, escapeAttr, scopeId, cssValue } from '../../utils/escape';
|
||||
|
||||
interface CountdownProps {
|
||||
targetDate?: string;
|
||||
heading?: string;
|
||||
style?: CSSProperties;
|
||||
digitColor?: string;
|
||||
labelColor?: string;
|
||||
bgColor?: string;
|
||||
anchorId?: string;
|
||||
animation?: string;
|
||||
animationDelay?: string;
|
||||
hideOnDesktop?: boolean;
|
||||
hideOnTablet?: boolean;
|
||||
hideOnMobile?: boolean;
|
||||
}
|
||||
|
||||
interface TimeLeft {
|
||||
days: number;
|
||||
hours: number;
|
||||
minutes: number;
|
||||
seconds: number;
|
||||
}
|
||||
|
||||
function getDefaultTargetDate(): string {
|
||||
const d = new Date();
|
||||
d.setDate(d.getDate() + 30);
|
||||
return d.toISOString().split('T')[0];
|
||||
}
|
||||
|
||||
function calcTimeLeft(target: string): TimeLeft {
|
||||
const diff = new Date(target).getTime() - Date.now();
|
||||
if (diff <= 0) return { days: 0, hours: 0, minutes: 0, seconds: 0 };
|
||||
return {
|
||||
days: Math.floor(diff / (1000 * 60 * 60 * 24)),
|
||||
hours: Math.floor((diff / (1000 * 60 * 60)) % 24),
|
||||
minutes: Math.floor((diff / (1000 * 60)) % 60),
|
||||
seconds: Math.floor((diff / 1000) % 60),
|
||||
};
|
||||
}
|
||||
|
||||
const DEFAULT_TARGET = getDefaultTargetDate();
|
||||
|
||||
export const Countdown: UserComponent<CountdownProps> = ({
|
||||
targetDate = DEFAULT_TARGET,
|
||||
heading = 'Coming Soon',
|
||||
style = {},
|
||||
digitColor = '#ffffff',
|
||||
labelColor = 'rgba(255,255,255,0.7)',
|
||||
bgColor = '#18181b',
|
||||
anchorId,
|
||||
}) => {
|
||||
const {
|
||||
connectors: { connect, drag },
|
||||
selected,
|
||||
} = useNode((node) => ({
|
||||
selected: node.events.selected,
|
||||
}));
|
||||
|
||||
const [timeLeft, setTimeLeft] = useState<TimeLeft>(() => calcTimeLeft(targetDate));
|
||||
|
||||
useEffect(() => {
|
||||
setTimeLeft(calcTimeLeft(targetDate));
|
||||
const interval = setInterval(() => {
|
||||
setTimeLeft(calcTimeLeft(targetDate));
|
||||
}, 1000);
|
||||
return () => clearInterval(interval);
|
||||
}, [targetDate]);
|
||||
|
||||
const pad = (n: number) => String(n).padStart(2, '0');
|
||||
|
||||
const boxStyle: CSSProperties = {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
gap: '4px',
|
||||
minWidth: '80px',
|
||||
};
|
||||
|
||||
const digitStyle: CSSProperties = {
|
||||
fontSize: '48px',
|
||||
fontWeight: '700',
|
||||
color: digitColor,
|
||||
lineHeight: '1',
|
||||
fontFamily: 'Inter, sans-serif',
|
||||
};
|
||||
|
||||
const unitLabelStyle: CSSProperties = {
|
||||
fontSize: '12px',
|
||||
color: labelColor,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.1em',
|
||||
fontFamily: 'Inter, sans-serif',
|
||||
};
|
||||
|
||||
const units: Array<{ label: string; value: number }> = [
|
||||
{ label: 'Days', value: timeLeft.days },
|
||||
{ label: 'Hours', value: timeLeft.hours },
|
||||
{ label: 'Minutes', value: timeLeft.minutes },
|
||||
{ label: 'Seconds', value: timeLeft.seconds },
|
||||
];
|
||||
|
||||
return (
|
||||
<section
|
||||
ref={(ref: HTMLElement | null): void => { if (ref) connect(drag(ref)); }}
|
||||
id={anchorId || undefined}
|
||||
style={{
|
||||
padding: '60px 20px',
|
||||
textAlign: 'center',
|
||||
backgroundColor: bgColor,
|
||||
outline: selected ? '2px solid #3b82f6' : 'none',
|
||||
...style,
|
||||
}}
|
||||
>
|
||||
{heading && (
|
||||
<h2 style={{ fontSize: '32px', fontWeight: '700', color: digitColor, marginBottom: '32px', fontFamily: 'Inter, sans-serif' }}>
|
||||
{heading}
|
||||
</h2>
|
||||
)}
|
||||
<div style={{ display: 'flex', justifyContent: 'center', gap: '24px', flexWrap: 'wrap' }}>
|
||||
{units.map((u) => (
|
||||
<div key={u.label} style={boxStyle}>
|
||||
<span style={digitStyle}>{pad(u.value)}</span>
|
||||
<span style={unitLabelStyle}>{u.label}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
/* ---------- Craft config ---------- */
|
||||
|
||||
Countdown.craft = {
|
||||
displayName: 'Countdown',
|
||||
props: {
|
||||
targetDate: DEFAULT_TARGET,
|
||||
heading: 'Coming Soon',
|
||||
style: {},
|
||||
digitColor: '#ffffff',
|
||||
labelColor: 'rgba(255,255,255,0.7)',
|
||||
bgColor: '#18181b',
|
||||
anchorId: '',
|
||||
animation: '',
|
||||
animationDelay: '',
|
||||
hideOnDesktop: false,
|
||||
hideOnTablet: false,
|
||||
hideOnMobile: false,
|
||||
},
|
||||
rules: {
|
||||
canDrag: () => true,
|
||||
canMoveIn: () => false,
|
||||
canMoveOut: () => true,
|
||||
},
|
||||
};
|
||||
|
||||
/* ---------- HTML export ---------- */
|
||||
|
||||
(Countdown as any).toHtml = (props: CountdownProps, _childrenHtml: string, nodeId?: string) => {
|
||||
const {
|
||||
targetDate = DEFAULT_TARGET,
|
||||
heading = 'Coming Soon',
|
||||
style = {},
|
||||
bgColor = '#18181b',
|
||||
} = props;
|
||||
// Sanitized -- raw string-interpolation sinks in the heading/digit/label
|
||||
// style attributes below.
|
||||
const digitColor = cssValue(props.digitColor) || '#ffffff';
|
||||
const labelColor = cssValue(props.labelColor) || 'rgba(255,255,255,0.7)';
|
||||
|
||||
const sectionStyle = cssPropsToString({
|
||||
padding: '60px 20px',
|
||||
textAlign: 'center',
|
||||
backgroundColor: bgColor,
|
||||
...style,
|
||||
});
|
||||
const idAttr = props.anchorId ? ` id="${escapeAttr(props.anchorId)}"` : '';
|
||||
|
||||
const headingHtml = heading
|
||||
? `<h2 style="font-size:32px;font-weight:700;color:${digitColor};margin-bottom:32px;font-family:Inter,sans-serif">${escapeHtml(heading)}</h2>`
|
||||
: '';
|
||||
|
||||
const boxStyle = 'display:flex;flex-direction:column;align-items:center;gap:4px;min-width:80px';
|
||||
const dStyle = `font-size:48px;font-weight:700;color:${digitColor};line-height:1;font-family:Inter,sans-serif`;
|
||||
const lStyle = `font-size:12px;color:${labelColor};text-transform:uppercase;letter-spacing:0.1em;font-family:Inter,sans-serif`;
|
||||
|
||||
// Deterministic AND unique id for this countdown instance's span ids and
|
||||
// getElementById() calls inside its inline script -- scoped on the Craft
|
||||
// node id so two Countdown instances (e.g. both left at default props)
|
||||
// don't collide and end up writing each other's digits.
|
||||
const uid = scopeId(nodeId, targetDate + '::' + heading, 'cd');
|
||||
|
||||
// Only accept a strict date/datetime shape before it's embedded in the
|
||||
// inline <script>; anything else falls back to "now" instead of letting
|
||||
// arbitrary text (e.g. `");alert(1)//`) break out of the new Date(...) call.
|
||||
const VALID_DATE_RE = /^\d{4}-\d{2}-\d{2}([T ][0-9:.\-+Z]*)?$/;
|
||||
const dateExpr = typeof targetDate === 'string' && VALID_DATE_RE.test(targetDate)
|
||||
? `new Date(${JSON.stringify(targetDate)})`
|
||||
: 'new Date()';
|
||||
|
||||
return {
|
||||
html: `<section${idAttr}${sectionStyle ? ` style="${sectionStyle}"` : ''}>
|
||||
${headingHtml}
|
||||
<div style="display:flex;justify-content:center;gap:24px;flex-wrap:wrap">
|
||||
<div style="${boxStyle}"><span id="${uid}_d" style="${dStyle}">00</span><span style="${lStyle}">Days</span></div>
|
||||
<div style="${boxStyle}"><span id="${uid}_h" style="${dStyle}">00</span><span style="${lStyle}">Hours</span></div>
|
||||
<div style="${boxStyle}"><span id="${uid}_m" style="${dStyle}">00</span><span style="${lStyle}">Minutes</span></div>
|
||||
<div style="${boxStyle}"><span id="${uid}_s" style="${dStyle}">00</span><span style="${lStyle}">Seconds</span></div>
|
||||
</div>
|
||||
<script>
|
||||
(function(){
|
||||
var target = ${dateExpr}.getTime();
|
||||
var timer = null;
|
||||
function pad(n){ return String(n).padStart(2,'0'); }
|
||||
function update(){
|
||||
var diff = target - Date.now();
|
||||
if(diff<=0){
|
||||
diff=0;
|
||||
if(timer){ clearInterval(timer); timer=null; }
|
||||
}
|
||||
var d = Math.floor(diff/(1000*60*60*24));
|
||||
var h = Math.floor((diff/(1000*60*60))%24);
|
||||
var m = Math.floor((diff/(1000*60))%60);
|
||||
var s = Math.floor((diff/1000)%60);
|
||||
document.getElementById("${uid}_d").textContent = pad(d);
|
||||
document.getElementById("${uid}_h").textContent = pad(h);
|
||||
document.getElementById("${uid}_m").textContent = pad(m);
|
||||
document.getElementById("${uid}_s").textContent = pad(s);
|
||||
}
|
||||
update();
|
||||
if(target - Date.now() > 0){
|
||||
timer = setInterval(update,1000);
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
</section>`,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,34 @@
|
||||
import { describe, test, expect } from 'vitest';
|
||||
import { FeaturesGrid } from './FeaturesGrid';
|
||||
|
||||
const toHtml = (FeaturesGrid as any).toHtml;
|
||||
|
||||
describe('FeaturesGrid.toHtml image sink uses safeImageUrl (data:image/svg+xml allowed)', () => {
|
||||
test('feat.image as a data:image/svg+xml value emits a non-empty <img src>', () => {
|
||||
const svgDataUri = 'data:image/svg+xml,%3Csvg%2F%3E';
|
||||
const features = [
|
||||
{ title: 'Feature', description: 'Desc', icon: '⚡', image: svgDataUri, imageAlt: 'alt' },
|
||||
];
|
||||
const { html } = toHtml({ features }, '');
|
||||
expect(html).toContain(`<img src="${svgDataUri}"`);
|
||||
});
|
||||
|
||||
test('feat.buttonUrl stays on safeUrl (data:image/svg+xml blocked as a navigation target)', () => {
|
||||
const features = [
|
||||
{ title: 'Feature', description: 'Desc', icon: '⚡', buttonText: 'Go', buttonUrl: 'data:image/svg+xml,<svg onload=alert(1)>' },
|
||||
];
|
||||
const { html } = toHtml({ features }, '');
|
||||
expect(html).toMatch(/<a href=""/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('FeaturesGrid.craft.props includes the box-model/animation/visibility rollout props', () => {
|
||||
test('animation, animationDelay, and all 3 hideOn* flags are declared (blank/false defaults)', () => {
|
||||
const props = (FeaturesGrid as any).craft.props;
|
||||
expect(props).toHaveProperty('animation', '');
|
||||
expect(props).toHaveProperty('animationDelay', '');
|
||||
expect(props).toHaveProperty('hideOnDesktop', false);
|
||||
expect(props).toHaveProperty('hideOnTablet', false);
|
||||
expect(props).toHaveProperty('hideOnMobile', false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,148 @@
|
||||
import React, { CSSProperties } from 'react';
|
||||
import { useNode, UserComponent } from '@craftjs/core';
|
||||
import { cssPropsToString } from '../../utils/style-helpers';
|
||||
import { escapeHtml, escapeAttr, safeUrl, safeImageUrl } from '../../utils/escape';
|
||||
|
||||
interface FeatureItem {
|
||||
title: string;
|
||||
description: string;
|
||||
icon: string;
|
||||
image?: string;
|
||||
imageAlt?: string;
|
||||
buttonText?: string;
|
||||
buttonUrl?: string;
|
||||
}
|
||||
|
||||
interface FeaturesGridProps {
|
||||
features?: FeatureItem[];
|
||||
style?: CSSProperties;
|
||||
anchorId?: string;
|
||||
animation?: string;
|
||||
animationDelay?: string;
|
||||
hideOnDesktop?: boolean;
|
||||
hideOnTablet?: boolean;
|
||||
hideOnMobile?: boolean;
|
||||
}
|
||||
|
||||
// Keys image/imageAlt/buttonText/buttonUrl are present (blank) on the defaults so
|
||||
// the guided panel's generic array editor (which derives fields from the first
|
||||
// item's keys) exposes inputs for them. An image renders whenever `image` is set.
|
||||
const defaultFeatures: FeatureItem[] = [
|
||||
{ title: 'Fast & Reliable', description: 'Built for performance with optimized loading and rock-solid uptime.', icon: '⚡', image: '', imageAlt: '', buttonText: '', buttonUrl: '' },
|
||||
{ title: 'Easy to Use', description: 'Intuitive drag-and-drop interface that anyone can master in minutes.', icon: '✨', image: '', imageAlt: '', buttonText: '', buttonUrl: '' },
|
||||
{ title: 'Fully Responsive', description: 'Looks great on every device, from phones to ultrawide monitors.', icon: '📱', image: '', imageAlt: '', buttonText: '', buttonUrl: '' },
|
||||
];
|
||||
|
||||
export const FeaturesGrid: UserComponent<FeaturesGridProps> = ({
|
||||
features = defaultFeatures,
|
||||
style = {},
|
||||
anchorId,
|
||||
}) => {
|
||||
const {
|
||||
connectors: { connect, drag },
|
||||
selected,
|
||||
} = useNode((node) => ({
|
||||
selected: node.events.selected,
|
||||
}));
|
||||
|
||||
return (
|
||||
<section
|
||||
ref={(ref: HTMLElement | null): void => { if (ref) connect(drag(ref)); }}
|
||||
id={anchorId || undefined}
|
||||
style={{
|
||||
padding: '80px 20px',
|
||||
backgroundColor: '#ffffff',
|
||||
outline: selected ? '2px solid #3b82f6' : 'none',
|
||||
...style,
|
||||
}}
|
||||
>
|
||||
<div style={{ maxWidth: '1100px', margin: '0 auto', display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: '32px' }}>
|
||||
{(Array.isArray(features) ? features : []).map((feat, i) => (
|
||||
<div
|
||||
key={i}
|
||||
style={{
|
||||
textAlign: 'center',
|
||||
padding: '32px 24px',
|
||||
borderRadius: '12px',
|
||||
backgroundColor: '#f8fafc',
|
||||
border: '1px solid #e2e8f0',
|
||||
}}
|
||||
>
|
||||
{feat.image ? (
|
||||
<img
|
||||
src={feat.image}
|
||||
alt={feat.imageAlt || feat.title || ''}
|
||||
style={{ maxWidth: '100%', height: 'auto', marginBottom: '16px', borderRadius: '8px' }}
|
||||
/>
|
||||
) : (
|
||||
<div style={{ fontSize: '36px', marginBottom: '16px' }}>{feat.icon}</div>
|
||||
)}
|
||||
<h3 style={{ fontSize: '20px', fontWeight: '600', color: '#18181b', marginBottom: '8px' }}>{feat.title}</h3>
|
||||
<p style={{ fontSize: '14px', color: '#64748b', lineHeight: '1.6' }}>{feat.description}</p>
|
||||
{feat.buttonText ? (
|
||||
<a
|
||||
href={feat.buttonUrl || '#'}
|
||||
onClick={(e) => e.preventDefault()}
|
||||
style={{ display: 'inline-block', marginTop: '16px', padding: '10px 24px', background: '#3b82f6', color: '#fff', borderRadius: '8px', textDecoration: 'none', fontSize: '14px', fontWeight: 600 }}
|
||||
>
|
||||
{feat.buttonText}
|
||||
</a>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
/* ---------- Craft config ---------- */
|
||||
|
||||
FeaturesGrid.craft = {
|
||||
displayName: 'Features Grid',
|
||||
props: {
|
||||
features: defaultFeatures,
|
||||
style: { backgroundColor: '#ffffff' },
|
||||
anchorId: '',
|
||||
animation: '',
|
||||
animationDelay: '',
|
||||
hideOnDesktop: false,
|
||||
hideOnTablet: false,
|
||||
hideOnMobile: false,
|
||||
},
|
||||
rules: {
|
||||
canDrag: () => true,
|
||||
canMoveIn: () => false,
|
||||
canMoveOut: () => true,
|
||||
},
|
||||
};
|
||||
|
||||
/* ---------- HTML export ---------- */
|
||||
|
||||
(FeaturesGrid as any).toHtml = (props: FeaturesGridProps, _childrenHtml: string) => {
|
||||
const sectionStyle = cssPropsToString({
|
||||
padding: '80px 20px',
|
||||
...props.style,
|
||||
});
|
||||
const idAttr = props.anchorId ? ` id="${escapeAttr(props.anchorId)}"` : '';
|
||||
const cards = (props.features || defaultFeatures).map((feat) => {
|
||||
const media = feat.image
|
||||
? `<img src="${escapeAttr(safeImageUrl(feat.image))}" alt="${escapeAttr(feat.imageAlt || feat.title || '')}" style="max-width:100%;height:auto;margin-bottom:16px;border-radius:8px">`
|
||||
: `<div style="font-size:36px;margin-bottom:16px">${escapeHtml(feat.icon)}</div>`;
|
||||
const button = feat.buttonText
|
||||
? `\n <a href="${escapeAttr(safeUrl(feat.buttonUrl || '#'))}" style="display:inline-block;margin-top:16px;padding:10px 24px;background:#3b82f6;color:#fff;border-radius:8px;text-decoration:none;font-size:14px;font-weight:600">${escapeHtml(feat.buttonText)}</a>`
|
||||
: '';
|
||||
return `<div style="text-align:center;padding:32px 24px;border-radius:12px;background-color:#f8fafc;border:1px solid #e2e8f0">
|
||||
${media}
|
||||
<h3 style="font-size:20px;font-weight:600;color:#18181b;margin-bottom:8px">${escapeHtml(feat.title)}</h3>
|
||||
<p style="font-size:14px;color:#64748b;line-height:1.6">${escapeHtml(feat.description)}</p>${button}
|
||||
</div>`;
|
||||
}).join('\n ');
|
||||
|
||||
return {
|
||||
html: `<section${idAttr}${sectionStyle ? ` style="${sectionStyle}"` : ''}>
|
||||
<div style="max-width:1100px;margin:0 auto;display:grid;grid-template-columns:repeat(3,1fr);gap:32px">
|
||||
${cards}
|
||||
</div>
|
||||
</section>`,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,151 @@
|
||||
import { describe, test, expect } from 'vitest';
|
||||
import { Gallery } from './Gallery';
|
||||
|
||||
const toHtml = (Gallery as any).toHtml;
|
||||
|
||||
describe('Gallery.toHtml lightbox uses a delegated listener, not per-item onclick (A4.3)', () => {
|
||||
test('no per-item inline onclick with interpolated src', () => {
|
||||
const { html } = toHtml({ images: [{ src: '/a.jpg', alt: 'a' }], lightbox: true }, '');
|
||||
expect(html).not.toMatch(/onclick="[^"]*_open\(/);
|
||||
expect(html).toContain('data-lb-src="/a.jpg"');
|
||||
});
|
||||
|
||||
test('a single-quote in src cannot break the handler (no per-item onclick at all)', () => {
|
||||
const { html } = toHtml({ images: [{ src: "/a'.jpg", alt: 'a' }], lightbox: true }, '');
|
||||
// no per-item onclick handler exists at all (delegated listener only)
|
||||
expect(html).not.toMatch(/onclick="[^"]*_open\(/);
|
||||
// the quote in src is entity-escaped in the data attribute, not raw
|
||||
expect(html).toContain('data-lb-src="/a'.jpg"');
|
||||
expect(html).not.toContain(`data-lb-src="/a'.jpg"`);
|
||||
});
|
||||
|
||||
test('emits exactly one delegated click listener', () => {
|
||||
const { html } = toHtml({ images: [{ src: '/a.jpg' }, { src: '/b.jpg' }], lightbox: true }, '');
|
||||
const matches = html.match(/addEventListener\(['"]click['"]/g) || [];
|
||||
expect(matches.length).toBe(1);
|
||||
});
|
||||
|
||||
test('lightbox=false: no data-lb-src, no script', () => {
|
||||
const { html } = toHtml({ images: [{ src: '/a.jpg' }], lightbox: false }, '');
|
||||
expect(html).not.toContain('data-lb-src');
|
||||
expect(html).not.toContain('<script>');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Gallery.toHtml lightbox accessibility (F1.3)', () => {
|
||||
test('lightbox overlay has role="dialog", aria-modal, and aria-label', () => {
|
||||
const { html } = toHtml({ images: [{ src: '/a.jpg', alt: 'a' }], lightbox: true }, '');
|
||||
expect(html).toMatch(/role="dialog"/);
|
||||
expect(html).toMatch(/aria-modal="true"/);
|
||||
expect(html).toMatch(/aria-label="[^"]+"/);
|
||||
});
|
||||
|
||||
test('Escape closes the lightbox via the inline script', () => {
|
||||
const { html } = toHtml({ images: [{ src: '/a.jpg', alt: 'a' }], lightbox: true }, '');
|
||||
expect(html).toMatch(/Escape/);
|
||||
});
|
||||
|
||||
test('thumbnails are keyboard-operable when lightbox is enabled (role=button + tabindex=0)', () => {
|
||||
const { html } = toHtml({ images: [{ src: '/a.jpg', alt: 'a' }], lightbox: true }, '');
|
||||
expect(html).toMatch(/data-lb-src="[^"]*"[^>]*role="button"[^>]*tabindex="0"/);
|
||||
});
|
||||
|
||||
test('delegated listener handles Enter/Space for keyboard activation', () => {
|
||||
const { html } = toHtml({ images: [{ src: '/a.jpg' }, { src: '/b.jpg' }], lightbox: true }, '');
|
||||
expect(html).toMatch(/addEventListener\(['"]keydown['"]/);
|
||||
});
|
||||
|
||||
test('lightbox=false: no role="dialog", no role="button" thumbnails', () => {
|
||||
const { html } = toHtml({ images: [{ src: '/a.jpg' }], lightbox: false }, '');
|
||||
expect(html).not.toContain('role="dialog"');
|
||||
expect(html).not.toContain('role="button"');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Gallery.toHtml deterministic + unique scope ids (thread node id, no Math.random)', () => {
|
||||
const props = { images: [{ src: '/a.jpg', alt: 'a' }], lightbox: true };
|
||||
|
||||
test('same node id -> identical output across calls (deterministic)', () => {
|
||||
const { html: html1 } = toHtml(props, '', 'node-gal1');
|
||||
const { html: html2 } = toHtml(props, '', 'node-gal1');
|
||||
expect(html1).toBe(html2);
|
||||
});
|
||||
|
||||
test('different node ids -> different, non-colliding gallery scope ids (identical images, no collision)', () => {
|
||||
const { html: html1 } = toHtml(props, '', 'node-gal1');
|
||||
const { html: html2 } = toHtml(props, '', 'node-gal2');
|
||||
const id1 = html1.match(/id="([^"]+)_overlay"/)![1];
|
||||
const id2 = html2.match(/id="([^"]+)_overlay"/)![1];
|
||||
expect(id1).not.toBe(id2);
|
||||
});
|
||||
|
||||
test('overlay/grid ids and the script function names all use the SAME scope', () => {
|
||||
const { html } = toHtml(props, '', 'node-gal1');
|
||||
const scope = html.match(/id="([^"]+)_overlay"/)![1];
|
||||
expect(html).toContain(`id="${scope}_grid"`);
|
||||
expect(html).toContain(`function ${scope}_close()`);
|
||||
expect(html).toContain(`function ${scope}_open(`);
|
||||
});
|
||||
|
||||
test('no nodeId (legacy 2-arg call): still deterministic across repeated calls, not random', () => {
|
||||
const { html: html1 } = toHtml(props, '');
|
||||
const { html: html2 } = toHtml(props, '');
|
||||
expect(html1).toBe(html2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Gallery.toHtml default SVG placeholder images survive export (Bug 2 regression)', () => {
|
||||
test('a default data:image/svg+xml image emits a non-empty img src, not src=""', () => {
|
||||
const { html } = toHtml({}, ''); // no images prop -> component default SVG placeholders
|
||||
expect(html).not.toContain('src=""');
|
||||
expect(html).toMatch(/src="data:image\/svg\+xml[^"]*"/);
|
||||
});
|
||||
|
||||
test('an explicit data:image/svg+xml gallery image src is preserved (not stripped to empty)', () => {
|
||||
const svg = 'data:image/svg+xml,%3Csvg%2F%3E';
|
||||
const { html } = toHtml({ images: [{ src: svg, alt: 'a' }] }, '');
|
||||
expect(html).toContain(`src="${svg}"`);
|
||||
});
|
||||
|
||||
test('lightbox data-lb-src also preserves data:image/svg+xml (still an image context)', () => {
|
||||
const svg = 'data:image/svg+xml,%3Csvg%2F%3E';
|
||||
const { html } = toHtml({ images: [{ src: svg, alt: 'a' }], lightbox: true }, '');
|
||||
expect(html).toContain(`data-lb-src="${svg}"`);
|
||||
});
|
||||
|
||||
test('a javascript: gallery image src still yields an empty src (safeImageUrl still blocks it)', () => {
|
||||
const { html } = toHtml({ images: [{ src: 'javascript:alert(1)', alt: 'a' }] }, '');
|
||||
expect(html).toContain('src=""');
|
||||
expect(html).not.toContain('javascript:');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Gallery.toHtml lightbox focus management (M-2)', () => {
|
||||
const props = { images: [{ src: '/a.jpg', alt: 'a' }], lightbox: true };
|
||||
|
||||
test('overlay includes a focusable close control with an accessible name and tabindex', () => {
|
||||
const { html } = toHtml(props, '', 'node-gal1');
|
||||
// A close control: a button (or the dialog container) with an accessible
|
||||
// name (aria-label) and an explicit tabindex so it's keyboard-focusable.
|
||||
expect(html).toMatch(/aria-label="[^"]*[Cc]lose[^"]*"[^>]*tabindex="-?\d+"|tabindex="-?\d+"[^>]*aria-label="[^"]*[Cc]lose[^"]*"/);
|
||||
});
|
||||
|
||||
test('script saves document.activeElement on open (for focus restore)', () => {
|
||||
const { html } = toHtml(props, '', 'node-gal1');
|
||||
expect(html).toMatch(/document\.activeElement/);
|
||||
});
|
||||
|
||||
test('script moves focus to the close control / dialog on open', () => {
|
||||
const { html } = toHtml(props, '', 'node-gal1');
|
||||
expect(html).toMatch(/\.focus\(\)/);
|
||||
});
|
||||
|
||||
test('script restores the previously-saved focus on close', () => {
|
||||
const { html } = toHtml(props, '', 'node-gal1');
|
||||
// The close function references a stored "last focused element" variable
|
||||
// and calls .focus() on it, not just moving focus INTO the dialog.
|
||||
const closeFnMatch = html.match(/function\s+\w+_close\s*\(\)\s*\{[^}]*\}/);
|
||||
expect(closeFnMatch).not.toBeNull();
|
||||
expect(closeFnMatch![0]).toMatch(/\.focus\(\)/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,233 @@
|
||||
import React, { CSSProperties } from 'react';
|
||||
import { useNode, UserComponent } from '@craftjs/core';
|
||||
import { cssPropsToString } from '../../utils/style-helpers';
|
||||
import { escapeHtml, escapeAttr, safeImageUrl, scopeId, cssValue } from '../../utils/escape';
|
||||
|
||||
interface GalleryImage {
|
||||
src: string;
|
||||
alt: string;
|
||||
caption?: string;
|
||||
}
|
||||
|
||||
interface GalleryProps {
|
||||
images?: GalleryImage[];
|
||||
columns?: number;
|
||||
gap?: string;
|
||||
style?: CSSProperties;
|
||||
lightbox?: boolean;
|
||||
}
|
||||
|
||||
const placeholderSvg = (index: number) => {
|
||||
const colors = ['#3b82f6', '#8b5cf6', '#10b981', '#f59e0b', '#ef4444', '#ec4899'];
|
||||
const color = colors[index % colors.length];
|
||||
return `data:image/svg+xml,${encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" width="400" height="300" viewBox="0 0 400 300"><rect fill="${color}" width="400" height="300" opacity="0.15"/><rect fill="${color}" x="150" y="100" width="100" height="100" rx="12" opacity="0.3"/><text x="200" y="160" text-anchor="middle" font-family="sans-serif" font-size="24" fill="${color}" opacity="0.6">${index + 1}</text></svg>`)}`;
|
||||
};
|
||||
|
||||
const defaultImages: GalleryImage[] = [
|
||||
{ src: placeholderSvg(0), alt: 'Gallery image 1', caption: 'First image' },
|
||||
{ src: placeholderSvg(1), alt: 'Gallery image 2', caption: 'Second image' },
|
||||
{ src: placeholderSvg(2), alt: 'Gallery image 3', caption: 'Third image' },
|
||||
{ src: placeholderSvg(3), alt: 'Gallery image 4', caption: 'Fourth image' },
|
||||
{ src: placeholderSvg(4), alt: 'Gallery image 5', caption: 'Fifth image' },
|
||||
{ src: placeholderSvg(5), alt: 'Gallery image 6', caption: 'Sixth image' },
|
||||
];
|
||||
|
||||
export const Gallery: UserComponent<GalleryProps> = ({
|
||||
images = defaultImages,
|
||||
columns = 3,
|
||||
gap = '16px',
|
||||
style = {},
|
||||
lightbox = false,
|
||||
}) => {
|
||||
const {
|
||||
connectors: { connect, drag },
|
||||
selected,
|
||||
} = useNode((node) => ({
|
||||
selected: node.events.selected,
|
||||
}));
|
||||
|
||||
return (
|
||||
<section
|
||||
ref={(ref: HTMLElement | null): void => { if (ref) connect(drag(ref)); }}
|
||||
style={{
|
||||
padding: '60px 20px',
|
||||
backgroundColor: '#ffffff',
|
||||
outline: selected ? '2px solid #3b82f6' : 'none',
|
||||
...style,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
maxWidth: '1100px',
|
||||
margin: '0 auto',
|
||||
display: 'grid',
|
||||
gridTemplateColumns: `repeat(${columns}, 1fr)`,
|
||||
gap: gap,
|
||||
}}
|
||||
>
|
||||
{images.map((img, i) => (
|
||||
<div key={i} style={{ position: 'relative', overflow: 'hidden', borderRadius: '8px' }}>
|
||||
<img
|
||||
src={img.src}
|
||||
alt={img.alt}
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '200px',
|
||||
objectFit: 'cover',
|
||||
display: 'block',
|
||||
borderRadius: '8px',
|
||||
backgroundColor: '#f1f5f9',
|
||||
}}
|
||||
/>
|
||||
{img.caption && (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: '0',
|
||||
left: '0',
|
||||
right: '0',
|
||||
padding: '8px 12px',
|
||||
background: 'linear-gradient(transparent, rgba(0,0,0,0.7))',
|
||||
color: '#ffffff',
|
||||
fontSize: '12px',
|
||||
borderBottomLeftRadius: '8px',
|
||||
borderBottomRightRadius: '8px',
|
||||
}}
|
||||
>
|
||||
{img.caption}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
/* ---------- Craft config ---------- */
|
||||
|
||||
Gallery.craft = {
|
||||
displayName: 'Gallery',
|
||||
props: {
|
||||
images: defaultImages,
|
||||
columns: 3,
|
||||
gap: '16px',
|
||||
style: { backgroundColor: '#ffffff' },
|
||||
lightbox: false,
|
||||
},
|
||||
rules: {
|
||||
canDrag: () => true,
|
||||
canMoveIn: () => false,
|
||||
canMoveOut: () => true,
|
||||
},
|
||||
};
|
||||
|
||||
/* ---------- HTML export ---------- */
|
||||
|
||||
(Gallery as any).toHtml = (props: GalleryProps, _childrenHtml: string, nodeId?: string) => {
|
||||
const sectionStyle = cssPropsToString({
|
||||
padding: '60px 20px',
|
||||
...props.style,
|
||||
});
|
||||
const images = props.images || defaultImages;
|
||||
// Number() coercion: `columns` is a raw string-interpolation sink into the
|
||||
// grid style attribute below (repeat(${columns},1fr)) -- a non-numeric
|
||||
// (e.g. hand-crafted/AI-generated tree) value would otherwise be able to
|
||||
// break out; Number() of anything non-numeric collapses safely to NaN.
|
||||
const columns = Number(props.columns) || 3;
|
||||
// Sanitized -- gap is a raw string-interpolation sink into the grid style
|
||||
// attribute below.
|
||||
const gap = cssValue(props.gap) || '16px';
|
||||
const lightbox = props.lightbox || false;
|
||||
|
||||
// Deterministic AND unique id, scoped on the Craft node id, for this
|
||||
// gallery's overlay/grid element ids and inline-script function names --
|
||||
// so two Gallery instances (e.g. both left at default images) don't
|
||||
// collide and end up sharing/clobbering one lightbox overlay.
|
||||
const galleryId = scopeId(nodeId, JSON.stringify(images) + columns + gap, 'gallery');
|
||||
|
||||
const items = images.map((img) => {
|
||||
const caption = img.caption
|
||||
? `<div style="position:absolute;bottom:0;left:0;right:0;padding:8px 12px;background:linear-gradient(transparent,rgba(0,0,0,0.7));color:#ffffff;font-size:12px;border-bottom-left-radius:8px;border-bottom-right-radius:8px">${escapeHtml(img.caption)}</div>`
|
||||
: '';
|
||||
// Lightbox items carry the image URL as a data attribute rather than an
|
||||
// inline onclick with an interpolated src -- a single delegated click
|
||||
// listener below reads it, so a src containing a quote can't break out
|
||||
// of a per-item event-handler string.
|
||||
const lbAttr = lightbox ? ` data-lb-src="${escapeAttr(safeImageUrl(img.src || ''))}" role="button" tabindex="0"` : '';
|
||||
const itemStyle = lightbox ? 'cursor:pointer;position:relative;overflow:hidden;border-radius:8px' : 'position:relative;overflow:hidden;border-radius:8px';
|
||||
return `<div${lbAttr} style="${itemStyle}">
|
||||
<img src="${escapeAttr(safeImageUrl(img.src || ''))}" alt="${escapeAttr(img.alt)}" style="width:100%;height:200px;object-fit:cover;display:block;border-radius:8px;background-color:#f1f5f9" />
|
||||
${caption}
|
||||
</div>`;
|
||||
}).join('\n ');
|
||||
|
||||
let lightboxHtml = '';
|
||||
let gridIdAttr = '';
|
||||
if (lightbox) {
|
||||
gridIdAttr = ` id="${galleryId}_grid"`;
|
||||
// M-2: focus management for the lightbox dialog.
|
||||
// - OPEN: stash `document.activeElement` (the thumbnail that triggered
|
||||
// the open) in a module-scoped var, then move focus onto the close
|
||||
// button -- so a screen-reader/keyboard user lands inside the dialog
|
||||
// instead of focus staying on (or silently falling back to <body>)
|
||||
// behind the now-visible overlay.
|
||||
// - Tab trap: while the overlay is open, every Tab keypress is
|
||||
// intercepted and refocuses the close button (the dialog's only
|
||||
// focusable control besides Escape/click-to-close), so focus can
|
||||
// never wander out into the page content hidden behind the overlay.
|
||||
// - CLOSE (Escape, backdrop click, or the close button): restore focus
|
||||
// to the element stashed on open.
|
||||
lightboxHtml = `
|
||||
<div id="${galleryId}_overlay" role="dialog" aria-modal="true" aria-label="Image preview" onclick="${galleryId}_close()" style="display:none;position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,0.9);z-index:9999;justify-content:center;align-items:center;cursor:pointer">
|
||||
<button type="button" id="${galleryId}_closebtn" aria-label="Close preview" tabindex="-1" onclick="event.stopPropagation();${galleryId}_close()" style="position:absolute;top:16px;right:16px;width:36px;height:36px;border-radius:50%;border:none;background:rgba(255,255,255,0.15);color:#ffffff;font-size:20px;line-height:1;cursor:pointer;display:flex;align-items:center;justify-content:center">×</button>
|
||||
<img id="${galleryId}_img" src="" alt="" style="max-width:90%;max-height:90%;object-fit:contain;border-radius:8px" />
|
||||
</div>
|
||||
<script>
|
||||
var ${galleryId}_lastFocus = null;
|
||||
function ${galleryId}_close(){
|
||||
document.getElementById('${galleryId}_overlay').style.display='none';
|
||||
if(${galleryId}_lastFocus && ${galleryId}_lastFocus.focus) ${galleryId}_lastFocus.focus();
|
||||
${galleryId}_lastFocus = null;
|
||||
}
|
||||
function ${galleryId}_open(src){
|
||||
${galleryId}_lastFocus = document.activeElement;
|
||||
var o = document.getElementById('${galleryId}_overlay');
|
||||
document.getElementById('${galleryId}_img').src = src;
|
||||
o.style.display = 'flex';
|
||||
var c = document.getElementById('${galleryId}_closebtn');
|
||||
if(c) c.focus();
|
||||
}
|
||||
document.getElementById('${galleryId}_grid').addEventListener('click', function(e){
|
||||
var t = e.target.closest('[data-lb-src]');
|
||||
if(!t) return;
|
||||
${galleryId}_open(t.getAttribute('data-lb-src'));
|
||||
});
|
||||
document.getElementById('${galleryId}_grid').addEventListener('keydown', function(e){
|
||||
if(e.key!=='Enter' && e.key!==' ') return;
|
||||
var t = e.target.closest('[data-lb-src]');
|
||||
if(!t) return;
|
||||
e.preventDefault();
|
||||
${galleryId}_open(t.getAttribute('data-lb-src'));
|
||||
});
|
||||
document.addEventListener('keydown', function(e){
|
||||
var o = document.getElementById('${galleryId}_overlay');
|
||||
if(!o || o.style.display==='none') return;
|
||||
if(e.key==='Escape'){ ${galleryId}_close(); return; }
|
||||
if(e.key==='Tab'){
|
||||
e.preventDefault();
|
||||
var c = document.getElementById('${galleryId}_closebtn');
|
||||
if(c) c.focus();
|
||||
}
|
||||
});
|
||||
</script>`;
|
||||
}
|
||||
|
||||
return {
|
||||
html: `<section${sectionStyle ? ` style="${sectionStyle}"` : ''}>
|
||||
<div${gridIdAttr} style="max-width:1100px;margin:0 auto;display:grid;grid-template-columns:repeat(${columns},1fr);gap:${gap}">
|
||||
${items}
|
||||
</div>${lightboxHtml}
|
||||
</section>`,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,35 @@
|
||||
import { describe, test, expect } from 'vitest';
|
||||
import { HeroSimple } from './HeroSimple';
|
||||
|
||||
const toHtml = (HeroSimple as any).toHtml;
|
||||
|
||||
describe('HeroSimple.toHtml textAlign enum sink (attacker-controlled prop, not enforced at runtime)', () => {
|
||||
test('malicious textAlign value cannot break out of the content div style attribute', () => {
|
||||
const { html } = toHtml({
|
||||
heading: 'Hi',
|
||||
subtitle: 'There',
|
||||
textAlign: 'center;"><script>alert(1)</script>',
|
||||
}, '');
|
||||
expect(html).not.toContain('<script>alert(1)</script>');
|
||||
expect(html).not.toContain('center;">');
|
||||
});
|
||||
|
||||
test('unrecognized textAlign value falls back to a safe default rather than being echoed raw', () => {
|
||||
const { html } = toHtml({ heading: 'Hi', subtitle: 'There', textAlign: 'not-a-real-value' as any }, '');
|
||||
expect(html).not.toContain('text-align:not-a-real-value');
|
||||
});
|
||||
|
||||
test('valid textAlign values are preserved', () => {
|
||||
const { html: left } = toHtml({ heading: 'Hi', subtitle: 'There', textAlign: 'left' }, '');
|
||||
expect(left).toContain('text-align:left');
|
||||
const { html: right } = toHtml({ heading: 'Hi', subtitle: 'There', textAlign: 'right' }, '');
|
||||
expect(right).toContain('text-align:right');
|
||||
});
|
||||
|
||||
test('normal default render is sane', () => {
|
||||
const { html } = toHtml({ heading: 'Welcome', subtitle: 'Sub text' }, '');
|
||||
expect(html).toContain('Welcome');
|
||||
expect(html).toContain('Sub text');
|
||||
expect(html).toContain('text-align:center');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,260 @@
|
||||
import React, { CSSProperties } from 'react';
|
||||
import { useNode, UserComponent } from '@craftjs/core';
|
||||
import { cssPropsToString } from '../../utils/style-helpers';
|
||||
import { CtaButton, normalizeCtas, ctaInlineStyle, ctasToHtml } from './_cta-helpers';
|
||||
import { escapeHtml, escapeAttr, safeUrl, cssValue } from '../../utils/escape';
|
||||
|
||||
interface HeroProps {
|
||||
heading?: string;
|
||||
subtitle?: string;
|
||||
/** New dynamic CTAs. When set (length > 0), legacy primary/secondary fields are ignored. */
|
||||
ctas?: CtaButton[];
|
||||
/** Legacy — kept for backwards compatibility with saved projects. */
|
||||
buttonText?: string;
|
||||
buttonHref?: string;
|
||||
secondaryButtonText?: string;
|
||||
secondaryButtonHref?: string;
|
||||
bgType?: 'color' | 'gradient' | 'image' | 'video';
|
||||
bgColor?: string;
|
||||
bgGradientFrom?: string;
|
||||
bgGradientTo?: string;
|
||||
bgGradientAngle?: number;
|
||||
bgImage?: string;
|
||||
bgVideo?: string;
|
||||
overlayColor?: string;
|
||||
overlayOpacity?: number;
|
||||
textColor?: string;
|
||||
buttonBgColor?: string;
|
||||
buttonTextColor?: string;
|
||||
minHeight?: string;
|
||||
verticalAlign?: 'top' | 'center' | 'bottom';
|
||||
textAlign?: 'left' | 'center' | 'right';
|
||||
anchorId?: string;
|
||||
style?: CSSProperties;
|
||||
}
|
||||
|
||||
// Helper: build the background CSS value
|
||||
function buildBackground(props: HeroProps): string {
|
||||
switch (props.bgType) {
|
||||
case 'gradient':
|
||||
return `linear-gradient(${props.bgGradientAngle || 135}deg, ${props.bgGradientFrom || '#667eea'}, ${props.bgGradientTo || '#764ba2'})`;
|
||||
case 'image':
|
||||
return props.bgImage ? `url('${props.bgImage}') center/cover no-repeat` : '#1e293b';
|
||||
case 'color':
|
||||
default:
|
||||
return props.bgColor || '#1e293b';
|
||||
}
|
||||
}
|
||||
|
||||
export const HeroSimple: UserComponent<HeroProps> = ({
|
||||
heading = 'Build Something Amazing',
|
||||
subtitle = 'Create beautiful websites without writing a single line of code.',
|
||||
ctas,
|
||||
buttonText,
|
||||
buttonHref,
|
||||
secondaryButtonText,
|
||||
secondaryButtonHref,
|
||||
bgType = 'color',
|
||||
bgColor = '#1e293b',
|
||||
bgGradientFrom = '#667eea',
|
||||
bgGradientTo = '#764ba2',
|
||||
bgGradientAngle = 135,
|
||||
bgImage = '',
|
||||
bgVideo = '',
|
||||
overlayColor = '#000000',
|
||||
overlayOpacity = 0,
|
||||
textColor = '#ffffff',
|
||||
buttonBgColor = '#3b82f6',
|
||||
buttonTextColor = '#ffffff',
|
||||
minHeight = '500px',
|
||||
verticalAlign = 'center',
|
||||
textAlign = 'center',
|
||||
anchorId,
|
||||
style = {},
|
||||
}) => {
|
||||
const { connectors: { connect, drag } } = useNode();
|
||||
|
||||
const bg = buildBackground({
|
||||
bgType, bgColor, bgGradientFrom, bgGradientTo, bgGradientAngle, bgImage,
|
||||
} as HeroProps);
|
||||
|
||||
const justifyMap = { top: 'flex-start', center: 'center', bottom: 'flex-end' };
|
||||
|
||||
const effectiveCtas = normalizeCtas({ ctas, buttonText, buttonHref, secondaryButtonText, secondaryButtonHref });
|
||||
const ctaDefaults = {
|
||||
primaryBg: buttonBgColor,
|
||||
primaryText: buttonTextColor,
|
||||
outlineText: textColor,
|
||||
};
|
||||
|
||||
return (
|
||||
<section
|
||||
ref={(ref: HTMLElement | null): void => { if (ref) connect(drag(ref)); }}
|
||||
id={anchorId || undefined}
|
||||
style={{
|
||||
...style,
|
||||
background: bgType !== 'image' ? bg : undefined,
|
||||
backgroundImage: bgType === 'image' && bgImage ? `url('${bgImage}')` : undefined,
|
||||
backgroundSize: bgType === 'image' ? 'cover' : undefined,
|
||||
backgroundPosition: bgType === 'image' ? 'center' : undefined,
|
||||
minHeight: minHeight === '100vh' ? '100vh' : minHeight,
|
||||
display: 'flex',
|
||||
alignItems: justifyMap[verticalAlign] || 'center',
|
||||
justifyContent: 'center',
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
padding: '60px 20px',
|
||||
}}
|
||||
>
|
||||
{/* Video background */}
|
||||
{bgType === 'video' && bgVideo && (
|
||||
<video
|
||||
src={bgVideo}
|
||||
autoPlay muted loop playsInline
|
||||
style={{
|
||||
position: 'absolute', top: 0, left: 0, width: '100%', height: '100%',
|
||||
objectFit: 'cover', zIndex: 0,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Overlay (renders AFTER video so it sits on top) */}
|
||||
{overlayOpacity > 0 && (
|
||||
<div style={{
|
||||
position: 'absolute', top: 0, left: 0, right: 0, bottom: 0,
|
||||
backgroundColor: overlayColor,
|
||||
opacity: overlayOpacity / 100,
|
||||
zIndex: 1,
|
||||
}} />
|
||||
)}
|
||||
|
||||
{/* Content */}
|
||||
<div style={{
|
||||
maxWidth: '800px',
|
||||
width: '100%',
|
||||
position: 'relative',
|
||||
zIndex: 2,
|
||||
textAlign: textAlign as any,
|
||||
}}>
|
||||
<h1 style={{
|
||||
fontSize: '48px', fontWeight: '700', color: textColor,
|
||||
marginBottom: '16px', lineHeight: '1.2',
|
||||
}}>
|
||||
{heading}
|
||||
</h1>
|
||||
<p style={{
|
||||
fontSize: '20px', color: textColor,
|
||||
opacity: 0.85, marginBottom: '32px', lineHeight: '1.6',
|
||||
whiteSpace: 'pre-line',
|
||||
}}>
|
||||
{subtitle}
|
||||
</p>
|
||||
<div style={{ display: 'flex', gap: '12px', justifyContent: textAlign === 'center' ? 'center' : textAlign === 'right' ? 'flex-end' : 'flex-start', flexWrap: 'wrap' }}>
|
||||
{effectiveCtas.map((cta, i) => (
|
||||
<a key={i} href={cta.href || '#'} onClick={(e) => e.preventDefault()}
|
||||
style={ctaInlineStyle(cta, ctaDefaults)}>
|
||||
{cta.text}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
/* ---------- Craft config ---------- */
|
||||
|
||||
HeroSimple.craft = {
|
||||
displayName: 'Hero',
|
||||
props: {
|
||||
heading: 'Build Something Amazing',
|
||||
subtitle: 'Create beautiful websites without writing a single line of code.',
|
||||
ctas: [
|
||||
{ text: 'Get Started', href: '#', variant: 'primary' },
|
||||
] as CtaButton[],
|
||||
bgType: 'color',
|
||||
bgColor: '#1e293b',
|
||||
bgGradientFrom: '#667eea',
|
||||
bgGradientTo: '#764ba2',
|
||||
bgGradientAngle: 135,
|
||||
bgImage: '',
|
||||
bgVideo: '',
|
||||
overlayColor: '#000000',
|
||||
overlayOpacity: 0,
|
||||
textColor: '#ffffff',
|
||||
buttonBgColor: '#3b82f6',
|
||||
buttonTextColor: '#ffffff',
|
||||
minHeight: '500px',
|
||||
verticalAlign: 'center',
|
||||
textAlign: 'center',
|
||||
anchorId: '',
|
||||
style: {},
|
||||
},
|
||||
rules: {
|
||||
canDrag: () => true,
|
||||
canMoveIn: () => false,
|
||||
canMoveOut: () => true,
|
||||
},
|
||||
};
|
||||
|
||||
/* ---------- HTML export ---------- */
|
||||
|
||||
(HeroSimple as any).toHtml = (props: HeroProps, _childrenHtml: string) => {
|
||||
const bg = buildBackground(props);
|
||||
const justifyMap: Record<string, string> = { top: 'flex-start', center: 'center', bottom: 'flex-end' };
|
||||
|
||||
const sectionStyle = cssPropsToString({
|
||||
background: props.bgType !== 'image' ? bg : undefined,
|
||||
backgroundImage: props.bgType === 'image' && props.bgImage ? `url('${props.bgImage}')` : undefined,
|
||||
backgroundSize: props.bgType === 'image' ? 'cover' : undefined,
|
||||
backgroundPosition: props.bgType === 'image' ? 'center' : undefined,
|
||||
minHeight: props.minHeight || '500px',
|
||||
display: 'flex',
|
||||
alignItems: justifyMap[props.verticalAlign || 'center'],
|
||||
justifyContent: 'center',
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
padding: '60px 20px',
|
||||
...props.style,
|
||||
});
|
||||
|
||||
let overlayHtml = '';
|
||||
if ((props.overlayOpacity || 0) > 0) {
|
||||
const overlayColor = cssValue(props.overlayColor) || '#000';
|
||||
overlayHtml = `<div style="position:absolute;top:0;left:0;right:0;bottom:0;background-color:${overlayColor};opacity:${(props.overlayOpacity || 0) / 100};z-index:1"></div>`;
|
||||
}
|
||||
|
||||
let videoHtml = '';
|
||||
if (props.bgType === 'video' && props.bgVideo) {
|
||||
videoHtml = `<video src="${escapeAttr(safeUrl(props.bgVideo))}" autoplay muted loop playsinline style="position:absolute;top:0;left:0;width:100%;height:100%;object-fit:cover;z-index:0"></video>`;
|
||||
}
|
||||
|
||||
// Allowlisted -- `textAlign` is declared as a 'left'|'center'|'right' union
|
||||
// but arrives unchecked via AI update_props / deserialized state; it is
|
||||
// interpolated raw into the content div's style attribute below, so any
|
||||
// other value must collapse to a known-safe default rather than being
|
||||
// echoed into the markup.
|
||||
const ALLOWED_TEXT_ALIGN = ['left', 'center', 'right'];
|
||||
const textAlign = ALLOWED_TEXT_ALIGN.includes(props.textAlign as string) ? (props.textAlign as string) : 'center';
|
||||
const justifyBtn = textAlign === 'center' ? 'center' : textAlign === 'right' ? 'flex-end' : 'flex-start';
|
||||
|
||||
const ctas = normalizeCtas(props);
|
||||
const buttonsHtml = ctasToHtml(ctas, {
|
||||
primaryBg: props.buttonBgColor || '#3b82f6',
|
||||
primaryText: props.buttonTextColor || '#fff',
|
||||
outlineText: props.textColor || '#fff',
|
||||
});
|
||||
|
||||
const idAttr = props.anchorId ? ` id="${escapeAttr(props.anchorId)}"` : '';
|
||||
const heroTextColor = cssValue(props.textColor) || '#fff';
|
||||
return {
|
||||
html: `<section${idAttr} style="${sectionStyle}">
|
||||
${videoHtml}${overlayHtml}
|
||||
<div style="max-width:800px;width:100%;position:relative;z-index:2;text-align:${textAlign}">
|
||||
<h1 style="font-size:48px;font-weight:700;color:${heroTextColor};margin-bottom:16px;line-height:1.2">${escapeHtml(props.heading || '')}</h1>
|
||||
<p style="font-size:20px;color:${heroTextColor};opacity:0.85;margin-bottom:32px;line-height:1.6;white-space:pre-line">${escapeHtml(props.subtitle || '')}</p>
|
||||
<div style="display:flex;gap:12px;justify-content:${justifyBtn};flex-wrap:wrap">${buttonsHtml}</div>
|
||||
</div>
|
||||
</section>`,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,86 @@
|
||||
import { describe, test, expect } from 'vitest';
|
||||
import { NumberCounter } from './NumberCounter';
|
||||
|
||||
const toHtml = (NumberCounter as any).toHtml;
|
||||
|
||||
const counters = [
|
||||
{ number: 150, suffix: '+', label: 'Projects' },
|
||||
{ number: 50, suffix: '+', label: 'Clients' },
|
||||
];
|
||||
|
||||
describe('NumberCounter.toHtml deterministic + unique scope ids (thread node id, no Math.random)', () => {
|
||||
test('same node id -> identical output across calls (deterministic)', () => {
|
||||
const { html: html1 } = toHtml({ counters }, '', 'node-nc1');
|
||||
const { html: html2 } = toHtml({ counters }, '', 'node-nc1');
|
||||
expect(html1).toBe(html2);
|
||||
});
|
||||
|
||||
test('different node ids -> distinct, non-colliding nc_ scopes (identical props, no collision)', () => {
|
||||
const { html: html1 } = toHtml({ counters }, '', 'node-nc1');
|
||||
const { html: html2 } = toHtml({ counters }, '', 'node-nc2');
|
||||
const wrapId1 = html1.match(/<div id="(nc_[^"]+)"/)![1];
|
||||
const wrapId2 = html2.match(/<div id="(nc_[^"]+)"/)![1];
|
||||
expect(wrapId1).not.toBe(wrapId2);
|
||||
});
|
||||
|
||||
test('wrapper id, per-counter ids, and inline script agree on the same uid', () => {
|
||||
const { html } = toHtml({ counters }, '', 'node-nc1');
|
||||
const wrapId = html.match(/<div id="(nc_[^"]+)"/)![1];
|
||||
expect(html).toContain(`id="${wrapId}_n0"`);
|
||||
expect(html).toContain(`id="${wrapId}_n1"`);
|
||||
expect(html).toContain(`var uid="${wrapId}"`);
|
||||
expect(html).toContain('document.getElementById(uid)');
|
||||
expect(html).toContain('document.getElementById(uid+"_n"+i)');
|
||||
});
|
||||
|
||||
test('no nodeId (legacy 2-arg call): still deterministic across repeated calls, not random', () => {
|
||||
const { html: html1 } = toHtml({ counters }, '');
|
||||
const { html: html2 } = toHtml({ counters }, '');
|
||||
expect(html1).toBe(html2);
|
||||
});
|
||||
|
||||
test('two different node ids never collide even with default (no counters override) props', () => {
|
||||
const { html: html1 } = toHtml({}, '', 'node-a');
|
||||
const { html: html2 } = toHtml({}, '', 'node-b');
|
||||
const wrapId1 = html1.match(/<div id="(nc_[^"]+)"/)![1];
|
||||
const wrapId2 = html2.match(/<div id="(nc_[^"]+)"/)![1];
|
||||
expect(wrapId1).not.toBe(wrapId2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('NumberCounter.toHtml counter.number is NOT runtime-type-checked -- must be sanitized before it reaches data-target', () => {
|
||||
test('a malicious counter.number cannot break out of the data-target attribute to inject a <script> tag', () => {
|
||||
const malicious = [
|
||||
{ number: '150"><script>alert(1)</script>', suffix: '+', label: 'Evil' },
|
||||
];
|
||||
const { html } = toHtml({ counters: malicious }, '', 'node-nc-evil1');
|
||||
expect(html).not.toContain('<script>alert(1)</script>');
|
||||
expect(html).not.toContain('"><script>');
|
||||
});
|
||||
|
||||
test('a malicious counter.number cannot break out of the data-target attribute to inject an onmouseover handler', () => {
|
||||
const malicious = [
|
||||
{ number: '150" onmouseover="alert(1)', suffix: '+', label: 'Evil' },
|
||||
];
|
||||
const { html } = toHtml({ counters: malicious }, '', 'node-nc-evil2');
|
||||
expect(html).not.toContain('onmouseover=');
|
||||
expect(html).not.toMatch(/data-target="150" onmouseover/);
|
||||
});
|
||||
|
||||
test('normal numeric counter.number values still render as data-target="150"', () => {
|
||||
const normal = [{ number: 150, suffix: '+', label: 'Projects' }];
|
||||
const { html } = toHtml({ counters: normal }, '', 'node-nc-normal');
|
||||
expect(html).toContain('data-target="150"');
|
||||
});
|
||||
});
|
||||
|
||||
describe('NumberCounter.craft.props includes the box-model/animation/visibility rollout props', () => {
|
||||
test('animation, animationDelay, and all 3 hideOn* flags are declared (blank/false defaults)', () => {
|
||||
const props = (NumberCounter as any).craft.props;
|
||||
expect(props).toHaveProperty('animation', '');
|
||||
expect(props).toHaveProperty('animationDelay', '');
|
||||
expect(props).toHaveProperty('hideOnDesktop', false);
|
||||
expect(props).toHaveProperty('hideOnTablet', false);
|
||||
expect(props).toHaveProperty('hideOnMobile', false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,205 @@
|
||||
import React, { CSSProperties } from 'react';
|
||||
import { useNode, UserComponent } from '@craftjs/core';
|
||||
import { cssPropsToString } from '../../utils/style-helpers';
|
||||
import { escapeHtml, escapeAttr, scopeId, cssValue } from '../../utils/escape';
|
||||
|
||||
interface Counter {
|
||||
number: number;
|
||||
suffix: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface NumberCounterProps {
|
||||
counters?: Counter[];
|
||||
columns?: number;
|
||||
numberColor?: string;
|
||||
labelColor?: string;
|
||||
numberSize?: string;
|
||||
style?: CSSProperties;
|
||||
animation?: string;
|
||||
animationDelay?: string;
|
||||
hideOnDesktop?: boolean;
|
||||
hideOnTablet?: boolean;
|
||||
hideOnMobile?: boolean;
|
||||
}
|
||||
|
||||
const defaultCounters: Counter[] = [
|
||||
{ number: 150, suffix: '+', label: 'Projects' },
|
||||
{ number: 50, suffix: '+', label: 'Clients' },
|
||||
{ number: 10, suffix: '', label: 'Years' },
|
||||
{ number: 99, suffix: '%', label: 'Satisfaction' },
|
||||
];
|
||||
|
||||
export const NumberCounter: UserComponent<NumberCounterProps> = ({
|
||||
counters = defaultCounters,
|
||||
columns = 4,
|
||||
numberColor = '#3b82f6',
|
||||
labelColor = '#6b7280',
|
||||
numberSize = '48px',
|
||||
style = {},
|
||||
}) => {
|
||||
const {
|
||||
connectors: { connect, drag },
|
||||
selected,
|
||||
} = useNode((node) => ({
|
||||
selected: node.events.selected,
|
||||
}));
|
||||
|
||||
const items = counters.length > 0 ? counters : defaultCounters;
|
||||
|
||||
return (
|
||||
<section
|
||||
ref={(ref: HTMLElement | null): void => { if (ref) connect(drag(ref)); }}
|
||||
style={{
|
||||
padding: '60px 20px',
|
||||
backgroundColor: '#ffffff',
|
||||
outline: selected ? '2px solid #3b82f6' : 'none',
|
||||
...style,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
maxWidth: '1100px',
|
||||
margin: '0 auto',
|
||||
display: 'grid',
|
||||
gridTemplateColumns: `repeat(${columns}, 1fr)`,
|
||||
gap: '32px',
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
{items.map((counter, i) => (
|
||||
<div key={i} style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '8px' }}>
|
||||
<span style={{
|
||||
fontSize: numberSize,
|
||||
fontWeight: '700',
|
||||
color: numberColor,
|
||||
lineHeight: '1.1',
|
||||
fontFamily: 'Inter, sans-serif',
|
||||
}}>
|
||||
{counter.number}{counter.suffix}
|
||||
</span>
|
||||
<span style={{
|
||||
fontSize: '15px',
|
||||
color: labelColor,
|
||||
fontFamily: 'Inter, sans-serif',
|
||||
fontWeight: '500',
|
||||
}}>
|
||||
{counter.label}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
/* ---------- Craft config ---------- */
|
||||
|
||||
NumberCounter.craft = {
|
||||
displayName: 'Number Counter',
|
||||
props: {
|
||||
counters: defaultCounters,
|
||||
columns: 4,
|
||||
numberColor: '#3b82f6',
|
||||
labelColor: '#6b7280',
|
||||
numberSize: '48px',
|
||||
style: { backgroundColor: '#ffffff' },
|
||||
animation: '',
|
||||
animationDelay: '',
|
||||
hideOnDesktop: false,
|
||||
hideOnTablet: false,
|
||||
hideOnMobile: false,
|
||||
},
|
||||
rules: {
|
||||
canDrag: () => true,
|
||||
canMoveIn: () => false,
|
||||
canMoveOut: () => true,
|
||||
},
|
||||
};
|
||||
|
||||
/* ---------- HTML export ---------- */
|
||||
|
||||
(NumberCounter as any).toHtml = (props: NumberCounterProps, _childrenHtml: string, nodeId?: string) => {
|
||||
const {
|
||||
counters = defaultCounters,
|
||||
style = {},
|
||||
} = props;
|
||||
// Number() coercion: `columns` is a raw string-interpolation sink into the
|
||||
// grid style attribute below (repeat(${columns},1fr)); Number() of
|
||||
// anything non-numeric collapses safely to NaN instead of breaking out.
|
||||
const columns = Number(props.columns) || 4;
|
||||
// Sanitized -- raw string-interpolation sinks in the counter/label spans
|
||||
// below.
|
||||
const numberColor = cssValue(props.numberColor) || '#3b82f6';
|
||||
const labelColor = cssValue(props.labelColor) || '#6b7280';
|
||||
const numberSize = cssValue(props.numberSize) || '48px';
|
||||
|
||||
const items = counters.length > 0 ? counters : defaultCounters;
|
||||
|
||||
// Deterministic AND unique id for this counter instance's wrapper/span
|
||||
// ids and getElementById() calls inside its inline script -- scoped on
|
||||
// the Craft node id so two NumberCounter instances (e.g. both left at
|
||||
// default props) don't collide and end up animating each other's digits.
|
||||
const seed = items.map((c) => `${c.number}${c.suffix}::${c.label}`).join('|');
|
||||
const uid = scopeId(nodeId, seed, 'nc');
|
||||
|
||||
const sectionStyle = cssPropsToString({
|
||||
padding: '60px 20px',
|
||||
backgroundColor: '#ffffff',
|
||||
...style,
|
||||
});
|
||||
|
||||
const countersHtml = items.map((counter, i) => {
|
||||
// Number() coercion + escapeAttr: `counter.number` is declared `number`
|
||||
// per-item inside an array prop, but is NOT type-checked at runtime --
|
||||
// it arrives raw via the AI `update_props` path or a deserialized
|
||||
// saved-state blob and was previously interpolated straight into this
|
||||
// data-target attribute, letting a string like `150"><script>...`
|
||||
// break out of the attribute and inject markup. Number() collapses any
|
||||
// non-numeric value safely to NaN (then 0), and escapeAttr is kept as
|
||||
// defense-in-depth in case Number()'s string coercion output ever
|
||||
// contains a stray character (it can't today, but the sink should never
|
||||
// rely solely on the coercion).
|
||||
const numberVal = Number(counter.number);
|
||||
const safeNumber = Number.isFinite(numberVal) ? numberVal : 0;
|
||||
return `<div style="display:flex;flex-direction:column;align-items:center;gap:8px">
|
||||
<span id="${uid}_n${i}" data-target="${escapeAttr(String(safeNumber))}" data-suffix="${escapeAttr(counter.suffix)}" style="font-size:${numberSize};font-weight:700;color:${numberColor};line-height:1.1;font-family:Inter,sans-serif">0${escapeHtml(counter.suffix)}</span>
|
||||
<span style="font-size:15px;color:${labelColor};font-family:Inter,sans-serif;font-weight:500">${escapeHtml(counter.label)}</span>
|
||||
</div>`;
|
||||
}).join('\n ');
|
||||
|
||||
return {
|
||||
html: `<section${sectionStyle ? ` style="${sectionStyle}"` : ''}>
|
||||
<div id="${uid}" style="max-width:1100px;margin:0 auto;display:grid;grid-template-columns:repeat(${columns},1fr);gap:32px;text-align:center">
|
||||
${countersHtml}
|
||||
</div>
|
||||
<script>
|
||||
(function(){
|
||||
var uid="${uid}",started=false;
|
||||
function animate(){
|
||||
if(started)return;started=true;
|
||||
for(var i=0;i<${items.length};i++){
|
||||
(function(el){
|
||||
var target=parseInt(el.getAttribute("data-target")),
|
||||
suffix=el.getAttribute("data-suffix")||"",
|
||||
current=0,
|
||||
step=Math.max(1,Math.floor(target/60)),
|
||||
timer=setInterval(function(){
|
||||
current+=step;
|
||||
if(current>=target){current=target;clearInterval(timer);}
|
||||
el.textContent=current+suffix;
|
||||
},16);
|
||||
})(document.getElementById(uid+"_n"+i));
|
||||
}
|
||||
}
|
||||
if("IntersectionObserver"in window){
|
||||
var obs=new IntersectionObserver(function(entries){
|
||||
entries.forEach(function(e){if(e.isIntersecting){animate();obs.disconnect();}});
|
||||
},{threshold:0.2});
|
||||
obs.observe(document.getElementById(uid));
|
||||
}else{animate();}
|
||||
})();
|
||||
</script>
|
||||
</section>`,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,105 @@
|
||||
import { describe, test, expect } from 'vitest';
|
||||
import { PricingTable } from './PricingTable';
|
||||
|
||||
const toHtml = (PricingTable as any).toHtml;
|
||||
|
||||
const plans = [
|
||||
{ name: 'Basic', price: '$9', period: '/month', features: ['Feature A'], buttonText: 'Buy', buttonHref: '#', isFeatured: false },
|
||||
{ name: 'Pro', price: '$29', period: '/month', features: ['Feature B'], buttonText: 'Buy', buttonHref: '#', isFeatured: true },
|
||||
];
|
||||
|
||||
describe('PricingTable.toHtml regular-card color overrides (previously hard-coded, now real props)', () => {
|
||||
test('cardBg emits as the non-featured card background', () => {
|
||||
const { html } = toHtml({ plans, cardBg: '#f0f0f0' }, '');
|
||||
expect(html).toContain('background-color:#f0f0f0');
|
||||
});
|
||||
|
||||
test('textColor emits as the non-featured heading/price color', () => {
|
||||
const { html } = toHtml({ plans, textColor: '#123456' }, '');
|
||||
expect(html).toContain('color:#123456');
|
||||
});
|
||||
|
||||
test('subColor emits as the non-featured period text color', () => {
|
||||
const { html } = toHtml({ plans, subColor: '#abcdef' }, '');
|
||||
expect(html).toContain('color:#abcdef');
|
||||
});
|
||||
|
||||
test('featColor emits as the non-featured feature list text color', () => {
|
||||
const { html } = toHtml({ plans, featColor: '#334455' }, '');
|
||||
expect(html).toContain('color:#334455');
|
||||
});
|
||||
|
||||
test('checkColor emits as the non-featured bullet color', () => {
|
||||
const { html } = toHtml({ plans, checkColor: '#00ff00' }, '');
|
||||
expect(html).toContain('color:#00ff00');
|
||||
});
|
||||
|
||||
test('btnBg/btnColor emit as the non-featured button colors', () => {
|
||||
const { html } = toHtml({ plans, btnBg: '#111111', btnColor: '#eeeeee' }, '');
|
||||
expect(html).toContain('background-color:#111111');
|
||||
expect(html).toContain('color:#eeeeee');
|
||||
});
|
||||
|
||||
test('unset overrides fall back to the exact prior literals (backward compatible)', () => {
|
||||
const { html } = toHtml({ plans }, '');
|
||||
// Regular (non-featured) card literals unchanged from before these props existed.
|
||||
expect(html).toContain('background-color:#ffffff');
|
||||
expect(html).toContain('color:#18181b');
|
||||
expect(html).toContain('color:#64748b');
|
||||
expect(html).toContain('color:#4b5563');
|
||||
expect(html).toContain('color:#10b981');
|
||||
});
|
||||
|
||||
test('unset btnBg falls back to featuredBg (original derivation)', () => {
|
||||
const { html } = toHtml({ plans, featuredBg: '#654321' }, '');
|
||||
expect(html).toContain('background-color:#654321');
|
||||
});
|
||||
});
|
||||
|
||||
describe('PricingTable.toHtml XSS hardening (new card color props into style=)', () => {
|
||||
test('a cardBg breakout string cannot escape style=""', () => {
|
||||
const malicious = '#fff" onmouseover="alert(1)';
|
||||
const { html } = toHtml({ plans, cardBg: malicious }, '');
|
||||
expect(html).not.toMatch(/"\s+onmouseover="/);
|
||||
});
|
||||
|
||||
test('a textColor breakout string cannot escape style=""', () => {
|
||||
const malicious = '#000" onmouseover="alert(1)';
|
||||
const { html } = toHtml({ plans, textColor: malicious }, '');
|
||||
expect(html).not.toMatch(/"\s+onmouseover="/);
|
||||
});
|
||||
|
||||
test('a btnBg breakout string cannot escape style=""', () => {
|
||||
const malicious = '#000" onmouseover="alert(1)';
|
||||
const { html } = toHtml({ plans, btnBg: malicious }, '');
|
||||
expect(html).not.toMatch(/"\s+onmouseover="/);
|
||||
});
|
||||
|
||||
test('a btnColor breakout string cannot escape style=""', () => {
|
||||
const malicious = '#000" onmouseover="alert(1)';
|
||||
const { html } = toHtml({ plans, btnColor: malicious }, '');
|
||||
expect(html).not.toMatch(/"\s+onmouseover="/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('PricingTable.craft.props includes the box-model/animation/visibility rollout props', () => {
|
||||
test('the new regular-card color props are declared blank by default', () => {
|
||||
const props = (PricingTable as any).craft.props;
|
||||
expect(props).toHaveProperty('cardBg', '');
|
||||
expect(props).toHaveProperty('textColor', '');
|
||||
expect(props).toHaveProperty('subColor', '');
|
||||
expect(props).toHaveProperty('featColor', '');
|
||||
expect(props).toHaveProperty('checkColor', '');
|
||||
expect(props).toHaveProperty('btnBg', '');
|
||||
expect(props).toHaveProperty('btnColor', '');
|
||||
});
|
||||
|
||||
test('animation, animationDelay, and all 3 hideOn* flags are declared (blank/false defaults)', () => {
|
||||
const props = (PricingTable as any).craft.props;
|
||||
expect(props).toHaveProperty('animation', '');
|
||||
expect(props).toHaveProperty('animationDelay', '');
|
||||
expect(props).toHaveProperty('hideOnDesktop', false);
|
||||
expect(props).toHaveProperty('hideOnTablet', false);
|
||||
expect(props).toHaveProperty('hideOnMobile', false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,329 @@
|
||||
import React, { CSSProperties } from 'react';
|
||||
import { useNode, UserComponent } from '@craftjs/core';
|
||||
import { cssPropsToString } from '../../utils/style-helpers';
|
||||
import { escapeHtml, escapeAttr, safeUrl, cssValue } from '../../utils/escape';
|
||||
|
||||
interface PricingPlan {
|
||||
name: string;
|
||||
price: string;
|
||||
period: string;
|
||||
features: string[];
|
||||
buttonText: string;
|
||||
buttonHref: string;
|
||||
isFeatured: boolean;
|
||||
}
|
||||
|
||||
interface PricingTableProps {
|
||||
plans?: PricingPlan[];
|
||||
style?: CSSProperties;
|
||||
featuredBg?: string;
|
||||
bulletType?: string;
|
||||
anchorId?: string;
|
||||
/* ---- Regular (non-featured) card colors ----
|
||||
All optional; each falls back to the exact literal the card was
|
||||
previously hard-coded to (or, for the button, to featuredBg -- the
|
||||
button's original derivation) when left unset, so existing saved
|
||||
projects render pixel-identical until a color is explicitly picked. */
|
||||
cardBg?: string;
|
||||
textColor?: string;
|
||||
subColor?: string;
|
||||
featColor?: string;
|
||||
checkColor?: string;
|
||||
btnBg?: string;
|
||||
btnColor?: string;
|
||||
animation?: string;
|
||||
animationDelay?: string;
|
||||
hideOnDesktop?: boolean;
|
||||
hideOnTablet?: boolean;
|
||||
hideOnMobile?: boolean;
|
||||
}
|
||||
|
||||
const bulletChars: Record<string, string> = {
|
||||
check: '✓', dot: '●', arrow: '→', star: '★', dash: '—', none: '',
|
||||
};
|
||||
|
||||
const defaultPlans: PricingPlan[] = [
|
||||
{
|
||||
name: 'Basic',
|
||||
price: '$9',
|
||||
period: '/month',
|
||||
features: ['1 Website', '10 GB Storage', 'Free SSL Certificate', 'Email Support'],
|
||||
buttonText: 'Get Started',
|
||||
buttonHref: '#',
|
||||
isFeatured: false,
|
||||
},
|
||||
{
|
||||
name: 'Pro',
|
||||
price: '$29',
|
||||
period: '/month',
|
||||
features: ['10 Websites', '100 GB Storage', 'Free SSL Certificate', 'Priority Support', 'Custom Domain', 'Analytics Dashboard'],
|
||||
buttonText: 'Get Started',
|
||||
buttonHref: '#',
|
||||
isFeatured: true,
|
||||
},
|
||||
{
|
||||
name: 'Enterprise',
|
||||
price: '$99',
|
||||
period: '/month',
|
||||
features: ['Unlimited Websites', '1 TB Storage', 'Free SSL Certificate', '24/7 Phone Support', 'Custom Domain', 'Advanced Analytics', 'API Access', 'Team Collaboration'],
|
||||
buttonText: 'Contact Sales',
|
||||
buttonHref: '#',
|
||||
isFeatured: false,
|
||||
},
|
||||
];
|
||||
|
||||
export const PricingTable: UserComponent<PricingTableProps> = ({
|
||||
plans = defaultPlans,
|
||||
style = {},
|
||||
featuredBg = '#3b82f6',
|
||||
bulletType = 'check',
|
||||
anchorId,
|
||||
cardBg,
|
||||
textColor,
|
||||
subColor,
|
||||
featColor,
|
||||
checkColor,
|
||||
btnBg,
|
||||
btnColor,
|
||||
}) => {
|
||||
const {
|
||||
connectors: { connect, drag },
|
||||
selected,
|
||||
} = useNode((node) => ({
|
||||
selected: node.events.selected,
|
||||
}));
|
||||
|
||||
const regCardBg = cardBg || '#ffffff';
|
||||
const regTextColor = textColor || '#18181b';
|
||||
const regSubColor = subColor || '#64748b';
|
||||
const regFeatColor = featColor || '#4b5563';
|
||||
const regCheckColor = checkColor || '#10b981';
|
||||
const regBtnBg = btnBg || featuredBg;
|
||||
const regBtnColor = btnColor || '#ffffff';
|
||||
|
||||
return (
|
||||
<section
|
||||
ref={(ref: HTMLElement | null): void => { if (ref) connect(drag(ref)); }}
|
||||
id={anchorId || undefined}
|
||||
style={{
|
||||
padding: '80px 20px',
|
||||
backgroundColor: '#ffffff',
|
||||
outline: selected ? '2px solid #3b82f6' : 'none',
|
||||
...style,
|
||||
}}
|
||||
>
|
||||
<div style={{
|
||||
maxWidth: '1100px',
|
||||
margin: '0 auto',
|
||||
display: 'flex',
|
||||
gap: '24px',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'stretch',
|
||||
flexWrap: 'wrap',
|
||||
}}>
|
||||
{plans.map((plan, i) => (
|
||||
<div
|
||||
key={i}
|
||||
style={{
|
||||
flex: '1 1 280px',
|
||||
maxWidth: '360px',
|
||||
backgroundColor: plan.isFeatured ? featuredBg : regCardBg,
|
||||
border: plan.isFeatured ? 'none' : '1px solid #e2e8f0',
|
||||
borderRadius: '16px',
|
||||
padding: '40px 32px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
textAlign: 'center',
|
||||
position: 'relative',
|
||||
transform: plan.isFeatured ? 'scale(1.05)' : 'none',
|
||||
boxShadow: plan.isFeatured ? '0 20px 60px rgba(59,130,246,0.3)' : '0 1px 3px rgba(0,0,0,0.06)',
|
||||
}}
|
||||
>
|
||||
{plan.isFeatured && (
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
top: '-12px',
|
||||
backgroundColor: '#facc15',
|
||||
color: '#18181b',
|
||||
padding: '4px 16px',
|
||||
borderRadius: '9999px',
|
||||
fontSize: '12px',
|
||||
fontWeight: '700',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.5px',
|
||||
}}>
|
||||
Most Popular
|
||||
</div>
|
||||
)}
|
||||
<h3 style={{
|
||||
fontSize: '20px',
|
||||
fontWeight: '600',
|
||||
color: plan.isFeatured ? '#ffffff' : regTextColor,
|
||||
marginBottom: '8px',
|
||||
marginTop: plan.isFeatured ? '8px' : '0',
|
||||
}}>
|
||||
{plan.name}
|
||||
</h3>
|
||||
<div style={{ marginBottom: '24px' }}>
|
||||
<span style={{
|
||||
fontSize: '48px',
|
||||
fontWeight: '700',
|
||||
color: plan.isFeatured ? '#ffffff' : regTextColor,
|
||||
lineHeight: '1',
|
||||
}}>
|
||||
{plan.price}
|
||||
</span>
|
||||
<span style={{
|
||||
fontSize: '16px',
|
||||
color: plan.isFeatured ? 'rgba(255,255,255,0.8)' : regSubColor,
|
||||
}}>
|
||||
{plan.period}
|
||||
</span>
|
||||
</div>
|
||||
<ul style={{
|
||||
listStyle: 'none',
|
||||
padding: '0',
|
||||
margin: '0 0 32px 0',
|
||||
width: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '12px',
|
||||
}}>
|
||||
{(Array.isArray(plan.features) ? plan.features : []).map((feature, fi) => (
|
||||
<li key={fi} style={{
|
||||
fontSize: '14px',
|
||||
color: plan.isFeatured ? 'rgba(255,255,255,0.9)' : regFeatColor,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
}}>
|
||||
<span style={{ color: plan.isFeatured ? '#bbf7d0' : regCheckColor, fontWeight: '700' }}>{bulletChars[bulletType] || '✓'}</span>
|
||||
{feature}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<a
|
||||
href={plan.buttonHref}
|
||||
onClick={(e) => e.preventDefault()}
|
||||
style={{
|
||||
marginTop: 'auto',
|
||||
display: 'inline-block',
|
||||
padding: '14px 32px',
|
||||
backgroundColor: plan.isFeatured ? '#ffffff' : regBtnBg,
|
||||
color: plan.isFeatured ? featuredBg : regBtnColor,
|
||||
textDecoration: 'none',
|
||||
borderRadius: '8px',
|
||||
fontWeight: '600',
|
||||
fontSize: '14px',
|
||||
width: '100%',
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
{plan.buttonText}
|
||||
</a>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
/* ---------- Craft config ---------- */
|
||||
|
||||
PricingTable.craft = {
|
||||
displayName: 'Pricing Table',
|
||||
props: {
|
||||
plans: defaultPlans,
|
||||
style: { backgroundColor: '#ffffff' },
|
||||
featuredBg: '#3b82f6',
|
||||
bulletType: 'check',
|
||||
anchorId: '',
|
||||
cardBg: '',
|
||||
textColor: '',
|
||||
subColor: '',
|
||||
featColor: '',
|
||||
checkColor: '',
|
||||
btnBg: '',
|
||||
btnColor: '',
|
||||
animation: '',
|
||||
animationDelay: '',
|
||||
hideOnDesktop: false,
|
||||
hideOnTablet: false,
|
||||
hideOnMobile: false,
|
||||
},
|
||||
rules: {
|
||||
canDrag: () => true,
|
||||
canMoveIn: () => false,
|
||||
canMoveOut: () => true,
|
||||
},
|
||||
};
|
||||
|
||||
/* ---------- HTML export ---------- */
|
||||
|
||||
(PricingTable as any).toHtml = (props: PricingTableProps, _childrenHtml: string) => {
|
||||
const bulletType = props.bulletType || 'check';
|
||||
const sectionStyle = cssPropsToString({
|
||||
padding: '80px 20px',
|
||||
...props.style,
|
||||
});
|
||||
const idAttr = props.anchorId ? ` id="${escapeAttr(props.anchorId)}"` : '';
|
||||
const plans = props.plans || defaultPlans;
|
||||
// Sanitized -- featuredBg is a raw string-interpolation sink below (drives
|
||||
// cardBg/btnBg/btnColor, all raw-interpolated into style="...").
|
||||
const featuredBg = cssValue(props.featuredBg) || '#3b82f6';
|
||||
// Sanitized -- regular (non-featured) card color overrides, all raw
|
||||
// string-interpolation sinks into style="..." below. Each falls back to
|
||||
// the exact literal the card was previously hard-coded to (or, for the
|
||||
// button, to featuredBg) when unset, so unmodified pricing tables render
|
||||
// identically to before these props existed.
|
||||
const regCardBg = cssValue(props.cardBg) || '#ffffff';
|
||||
const regTextColor = cssValue(props.textColor) || '#18181b';
|
||||
const regSubColor = cssValue(props.subColor) || '#64748b';
|
||||
const regFeatColor = cssValue(props.featColor) || '#4b5563';
|
||||
const regCheckColor = cssValue(props.checkColor) || '#10b981';
|
||||
const regBtnBg = cssValue(props.btnBg) || featuredBg;
|
||||
const regBtnColor = cssValue(props.btnColor) || '#ffffff';
|
||||
|
||||
const cards = plans.map((plan) => {
|
||||
const cardBg = plan.isFeatured ? featuredBg : regCardBg;
|
||||
const cardBorder = plan.isFeatured ? 'border:none;' : 'border:1px solid #e2e8f0;';
|
||||
const textColor = plan.isFeatured ? '#ffffff' : regTextColor;
|
||||
const subColor = plan.isFeatured ? 'rgba(255,255,255,0.8)' : regSubColor;
|
||||
const featColor = plan.isFeatured ? 'rgba(255,255,255,0.9)' : regFeatColor;
|
||||
const checkColor = plan.isFeatured ? '#bbf7d0' : regCheckColor;
|
||||
const btnBg = plan.isFeatured ? '#ffffff' : regBtnBg;
|
||||
const btnColor = plan.isFeatured ? featuredBg : regBtnColor;
|
||||
const scale = plan.isFeatured ? 'transform:scale(1.05);' : '';
|
||||
const shadow = plan.isFeatured ? 'box-shadow:0 20px 60px rgba(59,130,246,0.3);' : 'box-shadow:0 1px 3px rgba(0,0,0,0.06);';
|
||||
|
||||
const featuresHtml = (Array.isArray(plan.features) ? plan.features : []).map((f) =>
|
||||
`<li style="font-size:14px;color:${featColor};display:flex;align-items:center;gap:8px"><span style="color:${checkColor};font-weight:700">${bulletChars[bulletType] || '✓'}</span>${escapeHtml(f)}</li>`
|
||||
).join('\n ');
|
||||
|
||||
const badge = plan.isFeatured
|
||||
? `<div style="position:absolute;top:-12px;background-color:#facc15;color:#18181b;padding:4px 16px;border-radius:9999px;font-size:12px;font-weight:700;text-transform:uppercase;letter-spacing:0.5px">Most Popular</div>`
|
||||
: '';
|
||||
|
||||
return `<div style="flex:1 1 280px;max-width:360px;background-color:${cardBg};${cardBorder}border-radius:16px;padding:40px 32px;display:flex;flex-direction:column;align-items:center;text-align:center;position:relative;${scale}${shadow}">
|
||||
${badge}
|
||||
<h3 style="font-size:20px;font-weight:600;color:${textColor};margin-bottom:8px;${plan.isFeatured ? 'margin-top:8px;' : ''}">${escapeHtml(plan.name)}</h3>
|
||||
<div style="margin-bottom:24px">
|
||||
<span style="font-size:48px;font-weight:700;color:${textColor};line-height:1">${escapeHtml(plan.price)}</span>
|
||||
<span style="font-size:16px;color:${subColor}">${escapeHtml(plan.period)}</span>
|
||||
</div>
|
||||
<ul style="list-style:none;padding:0;margin:0 0 32px 0;width:100%;display:flex;flex-direction:column;gap:12px">
|
||||
${featuresHtml}
|
||||
</ul>
|
||||
<a href="${escapeAttr(safeUrl(plan.buttonHref || '#'))}" style="margin-top:auto;display:inline-block;padding:14px 32px;background-color:${btnBg};color:${btnColor};text-decoration:none;border-radius:8px;font-weight:600;font-size:14px;width:100%;text-align:center">${escapeHtml(plan.buttonText)}</a>
|
||||
</div>`;
|
||||
}).join('\n ');
|
||||
|
||||
return {
|
||||
html: `<section${idAttr}${sectionStyle ? ` style="${sectionStyle}"` : ''}>
|
||||
<div style="max-width:1100px;margin:0 auto;display:flex;gap:24px;justify-content:center;align-items:stretch;flex-wrap:wrap">
|
||||
${cards}
|
||||
</div>
|
||||
</section>`,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,93 @@
|
||||
import { describe, test, expect } from 'vitest';
|
||||
import { Tabs } from './Tabs';
|
||||
|
||||
const toHtml = (Tabs as any).toHtml;
|
||||
|
||||
const tabs = [
|
||||
{ label: 'Overview', content: 'Overview content' },
|
||||
{ label: 'Features', content: 'Features content' },
|
||||
{ label: 'Support', content: 'Support content' },
|
||||
];
|
||||
|
||||
describe('Tabs.toHtml accessibility (F1.2)', () => {
|
||||
test('tab button container has role="tablist"', () => {
|
||||
const { html } = toHtml({ tabs }, '');
|
||||
expect(html).toMatch(/role="tablist"/);
|
||||
});
|
||||
|
||||
test('each tab button has role="tab", aria-selected, aria-controls', () => {
|
||||
const { html } = toHtml({ tabs }, '');
|
||||
const buttonMatches = html.match(/<button[^>]*role="tab"[^>]*>/g) || [];
|
||||
expect(buttonMatches.length).toBe(3);
|
||||
expect(html).toMatch(/aria-selected="true"/);
|
||||
expect(html).toMatch(/aria-selected="false"/);
|
||||
expect(html).toMatch(/role="tab"[^>]*aria-controls="[^"]+"/);
|
||||
});
|
||||
|
||||
test('each panel has role="tabpanel" and aria-labelledby matching a tab id', () => {
|
||||
const { html } = toHtml({ tabs }, '');
|
||||
const panelMatches = html.match(/role="tabpanel"/g) || [];
|
||||
expect(panelMatches.length).toBe(3);
|
||||
|
||||
// aria-controls on the first tab button should point at an id that
|
||||
// actually exists as a panel's id.
|
||||
const controlsMatch = html.match(/role="tab"[^>]*aria-controls="([^"]+)"/);
|
||||
expect(controlsMatch).toBeTruthy();
|
||||
const controlledId = controlsMatch![1];
|
||||
expect(html).toContain(`id="${controlledId}"`);
|
||||
});
|
||||
|
||||
test('ids linking tab<->panel are deterministic (stable across repeated calls, no randomness)', () => {
|
||||
const { html: html1 } = toHtml({ tabs }, '');
|
||||
const { html: html2 } = toHtml({ tabs }, '');
|
||||
const id1 = html1.match(/role="tab"[^>]*aria-controls="([^"]+)"/)![1];
|
||||
const id2 = html2.match(/role="tab"[^>]*aria-controls="([^"]+)"/)![1];
|
||||
expect(id1).toBe(id2);
|
||||
});
|
||||
|
||||
test('arrow-key navigation is wired in the inline script', () => {
|
||||
const { html } = toHtml({ tabs }, '');
|
||||
expect(html).toMatch(/ArrowRight/);
|
||||
expect(html).toMatch(/ArrowLeft/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Tabs.toHtml deterministic + unique ids (thread node id, resolves id-collision finding)', () => {
|
||||
test('same node id -> identical output across calls (deterministic, no Math.random)', () => {
|
||||
const { html: html1 } = toHtml({ tabs }, '', 'node-tabs1');
|
||||
const { html: html2 } = toHtml({ tabs }, '', 'node-tabs1');
|
||||
expect(html1).toBe(html2);
|
||||
});
|
||||
|
||||
test('two instances with IDENTICAL default tab content but different node ids do not collide', () => {
|
||||
const { html: html1 } = toHtml({ tabs }, '', 'node-tabs1');
|
||||
const { html: html2 } = toHtml({ tabs }, '', 'node-tabs2');
|
||||
const id1 = html1.match(/role="tab"[^>]*aria-controls="([^"]+)"/)![1];
|
||||
const id2 = html2.match(/role="tab"[^>]*aria-controls="([^"]+)"/)![1];
|
||||
expect(id1).not.toBe(id2);
|
||||
});
|
||||
|
||||
test('aria-controls still matches an existing panel id after the node-id change (internal consistency preserved)', () => {
|
||||
const { html } = toHtml({ tabs }, '', 'node-tabs1');
|
||||
const controlsMatch = html.match(/role="tab"[^>]*aria-controls="([^"]+)"/);
|
||||
expect(controlsMatch).toBeTruthy();
|
||||
expect(html).toContain(`id="${controlsMatch![1]}"`);
|
||||
});
|
||||
|
||||
test('no nodeId (legacy 2-arg call): still deterministic across repeated calls, not random', () => {
|
||||
const { html: html1 } = toHtml({ tabs }, '');
|
||||
const { html: html2 } = toHtml({ tabs }, '');
|
||||
expect(html1).toBe(html2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Tabs.craft.props includes the box-model/animation/visibility rollout props', () => {
|
||||
test('animation, animationDelay, and all 3 hideOn* flags are declared (blank/false defaults)', () => {
|
||||
const props = (Tabs as any).craft.props;
|
||||
expect(props).toHaveProperty('animation', '');
|
||||
expect(props).toHaveProperty('animationDelay', '');
|
||||
expect(props).toHaveProperty('hideOnDesktop', false);
|
||||
expect(props).toHaveProperty('hideOnTablet', false);
|
||||
expect(props).toHaveProperty('hideOnMobile', false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,221 @@
|
||||
import React, { CSSProperties, useState } from 'react';
|
||||
import { useNode, UserComponent } from '@craftjs/core';
|
||||
import { cssPropsToString } from '../../utils/style-helpers';
|
||||
import { escapeHtml, escapeAttr, scopeId, cssValue } from '../../utils/escape';
|
||||
|
||||
interface TabItem {
|
||||
label: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
interface TabsProps {
|
||||
tabs?: TabItem[];
|
||||
style?: CSSProperties;
|
||||
activeTabBg?: string;
|
||||
activeTabColor?: string;
|
||||
inactiveTabBg?: string;
|
||||
inactiveTabColor?: string;
|
||||
contentBg?: string;
|
||||
anchorId?: string;
|
||||
animation?: string;
|
||||
animationDelay?: string;
|
||||
hideOnDesktop?: boolean;
|
||||
hideOnTablet?: boolean;
|
||||
hideOnMobile?: boolean;
|
||||
}
|
||||
|
||||
const defaultTabs: TabItem[] = [
|
||||
{ label: 'Overview', content: 'Welcome to our platform. We provide the tools you need to build, launch, and grow your online presence. Our intuitive interface makes it simple to get started in minutes.' },
|
||||
{ label: 'Features', content: 'Drag-and-drop editor, responsive templates, custom domains, analytics dashboard, SEO tools, and integrations with your favorite services. Everything you need in one place.' },
|
||||
{ label: 'Support', content: 'Our dedicated support team is available 24/7 to help you with any questions. Access our knowledge base, community forums, or reach out directly via live chat or email.' },
|
||||
];
|
||||
|
||||
export const Tabs: UserComponent<TabsProps> = ({
|
||||
tabs = defaultTabs,
|
||||
style = {},
|
||||
activeTabBg = '#3b82f6',
|
||||
activeTabColor = '#ffffff',
|
||||
inactiveTabBg = '#f1f5f9',
|
||||
inactiveTabColor = '#64748b',
|
||||
contentBg = '#ffffff',
|
||||
anchorId,
|
||||
}) => {
|
||||
const {
|
||||
connectors: { connect, drag },
|
||||
selected,
|
||||
} = useNode((node) => ({
|
||||
selected: node.events.selected,
|
||||
}));
|
||||
|
||||
const [activeIndex, setActiveIndex] = useState(0);
|
||||
|
||||
return (
|
||||
<section
|
||||
ref={(ref: HTMLElement | null): void => { if (ref) connect(drag(ref)); }}
|
||||
id={anchorId || undefined}
|
||||
style={{
|
||||
padding: '60px 20px',
|
||||
backgroundColor: '#ffffff',
|
||||
outline: selected ? '2px solid #3b82f6' : 'none',
|
||||
...style,
|
||||
}}
|
||||
>
|
||||
<div style={{ maxWidth: '800px', margin: '0 auto' }}>
|
||||
{/* Tab buttons */}
|
||||
<div style={{ display: 'flex', gap: '2px', borderBottom: '2px solid #e2e8f0' }}>
|
||||
{tabs.map((tab, i) => (
|
||||
<button
|
||||
key={i}
|
||||
onClick={() => setActiveIndex(i)}
|
||||
style={{
|
||||
padding: '12px 24px',
|
||||
fontSize: '14px',
|
||||
fontWeight: '600',
|
||||
border: 'none',
|
||||
borderTopLeftRadius: '8px',
|
||||
borderTopRightRadius: '8px',
|
||||
cursor: 'pointer',
|
||||
backgroundColor: i === activeIndex ? activeTabBg : inactiveTabBg,
|
||||
color: i === activeIndex ? activeTabColor : inactiveTabColor,
|
||||
transition: 'background-color 0.2s, color 0.2s',
|
||||
}}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{/* Content panel */}
|
||||
<div
|
||||
style={{
|
||||
padding: '24px',
|
||||
backgroundColor: contentBg,
|
||||
border: '1px solid #e2e8f0',
|
||||
borderTop: 'none',
|
||||
borderBottomLeftRadius: '8px',
|
||||
borderBottomRightRadius: '8px',
|
||||
fontSize: '14px',
|
||||
lineHeight: '1.7',
|
||||
color: '#4b5563',
|
||||
minHeight: '100px',
|
||||
}}
|
||||
>
|
||||
{tabs[activeIndex]?.content || ''}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
/* ---------- Craft config ---------- */
|
||||
|
||||
Tabs.craft = {
|
||||
displayName: 'Tabs',
|
||||
props: {
|
||||
tabs: defaultTabs,
|
||||
style: { backgroundColor: '#ffffff' },
|
||||
activeTabBg: '#3b82f6',
|
||||
activeTabColor: '#ffffff',
|
||||
inactiveTabBg: '#f1f5f9',
|
||||
inactiveTabColor: '#64748b',
|
||||
contentBg: '#ffffff',
|
||||
anchorId: '',
|
||||
animation: '',
|
||||
animationDelay: '',
|
||||
hideOnDesktop: false,
|
||||
hideOnTablet: false,
|
||||
hideOnMobile: false,
|
||||
},
|
||||
rules: {
|
||||
canDrag: () => true,
|
||||
canMoveIn: () => false,
|
||||
canMoveOut: () => true,
|
||||
},
|
||||
};
|
||||
|
||||
/* ---------- HTML export ---------- */
|
||||
|
||||
(Tabs as any).toHtml = (props: TabsProps, _childrenHtml: string, nodeId?: string) => {
|
||||
const sectionStyle = cssPropsToString({
|
||||
padding: '60px 20px',
|
||||
...props.style,
|
||||
});
|
||||
const idAttr = props.anchorId ? ` id="${escapeAttr(props.anchorId)}"` : '';
|
||||
const tabs = props.tabs || defaultTabs;
|
||||
// Sanitized -- raw string-interpolation sinks below, both into style="..."
|
||||
// attributes AND into an inline <script> as single-quoted JS string
|
||||
// literals (a stray `'` there breaks out of the JS string, not just CSS);
|
||||
// cssValue strips quotes too so it neutralizes both contexts at once.
|
||||
const activeTabBg = cssValue(props.activeTabBg) || '#3b82f6';
|
||||
const activeTabColor = cssValue(props.activeTabColor) || '#ffffff';
|
||||
const inactiveTabBg = cssValue(props.inactiveTabBg) || '#f1f5f9';
|
||||
const inactiveTabColor = cssValue(props.inactiveTabColor) || '#64748b';
|
||||
const contentBg = cssValue(props.contentBg) || '#ffffff';
|
||||
|
||||
// tabId scopes the functional wiring (onclick/getElementById) as well as
|
||||
// the ARIA tab<->panel linking ids. It must be BOTH deterministic (so
|
||||
// aria-controls/aria-labelledby reference the SAME id across repeated
|
||||
// exports of the same page) AND unique (so two Tabs instances with
|
||||
// identical/default content -- e.g. both left at the default tab set --
|
||||
// don't collide and clobber each other's script globals / ARIA links).
|
||||
// Scoping on the Craft node id gives both properties; when it's
|
||||
// unavailable (legacy 2-arg call sites) we fall back to a stable hash of
|
||||
// the tab content, matching the old (collision-prone but never random)
|
||||
// behavior.
|
||||
const scopeSeed = props.anchorId || tabs.map((t) => t.label).join('|') + '::' + tabs.length;
|
||||
const tabId = scopeId(nodeId, scopeSeed, 'tabs');
|
||||
|
||||
const tabButtons = tabs.map((tab, i) => {
|
||||
const isActive = i === 0;
|
||||
return `<button onclick="${tabId}_switch(${i})" id="${tabId}_btn_${i}" role="tab" aria-selected="${isActive ? 'true' : 'false'}" aria-controls="${tabId}_panel_${i}" tabindex="${isActive ? '0' : '-1'}" style="padding:12px 24px;font-size:14px;font-weight:600;border:none;border-top-left-radius:8px;border-top-right-radius:8px;cursor:pointer;background-color:${isActive ? activeTabBg : inactiveTabBg};color:${isActive ? activeTabColor : inactiveTabColor}">${escapeHtml(tab.label)}</button>`;
|
||||
}).join('\n ');
|
||||
|
||||
const tabPanels = tabs.map((tab, i) => {
|
||||
return `<div id="${tabId}_panel_${i}" role="tabpanel" aria-labelledby="${tabId}_btn_${i}" tabindex="0" style="padding:24px;background-color:${contentBg};border:1px solid #e2e8f0;border-top:none;border-bottom-left-radius:8px;border-bottom-right-radius:8px;font-size:14px;line-height:1.7;color:#4b5563;min-height:100px;${i !== 0 ? 'display:none' : ''}">${escapeHtml(tab.content)}</div>`;
|
||||
}).join('\n ');
|
||||
|
||||
const switchScript = `<script>
|
||||
function ${tabId}_switch(idx){
|
||||
var total=${tabs.length};
|
||||
for(var i=0;i<total;i++){
|
||||
document.getElementById('${tabId}_panel_'+i).style.display=i===idx?'':'none';
|
||||
var btn=document.getElementById('${tabId}_btn_'+i);
|
||||
btn.style.backgroundColor=i===idx?'${activeTabBg}':'${inactiveTabBg}';
|
||||
btn.style.color=i===idx?'${activeTabColor}':'${inactiveTabColor}';
|
||||
btn.setAttribute('aria-selected', i===idx ? 'true' : 'false');
|
||||
btn.setAttribute('tabindex', i===idx ? '0' : '-1');
|
||||
}
|
||||
}
|
||||
(function(){
|
||||
var total=${tabs.length};
|
||||
for(var i=0;i<total;i++){
|
||||
(function(idx){
|
||||
var btn=document.getElementById('${tabId}_btn_'+idx);
|
||||
btn.addEventListener('keydown', function(e){
|
||||
var next=null;
|
||||
if(e.key==='ArrowRight'){ next=(idx+1)%total; }
|
||||
else if(e.key==='ArrowLeft'){ next=(idx-1+total)%total; }
|
||||
else if(e.key==='Home'){ next=0; }
|
||||
else if(e.key==='End'){ next=total-1; }
|
||||
if(next!==null){
|
||||
e.preventDefault();
|
||||
${tabId}_switch(next);
|
||||
document.getElementById('${tabId}_btn_'+next).focus();
|
||||
}
|
||||
});
|
||||
})(i);
|
||||
}
|
||||
})();
|
||||
</script>`;
|
||||
|
||||
return {
|
||||
html: `<section${idAttr}${sectionStyle ? ` style="${sectionStyle}"` : ''}>
|
||||
<div style="max-width:800px;margin:0 auto">
|
||||
<div role="tablist" style="display:flex;gap:2px;border-bottom:2px solid #e2e8f0">
|
||||
${tabButtons}
|
||||
</div>
|
||||
${tabPanels}
|
||||
</div>
|
||||
${switchScript}
|
||||
</section>`,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,84 @@
|
||||
import { describe, test, expect } from 'vitest';
|
||||
import { Testimonials } from './Testimonials';
|
||||
|
||||
const toHtml = (Testimonials as any).toHtml;
|
||||
|
||||
const testimonials = [
|
||||
{ quote: 'Quote one', name: 'Name One', title: 'Title One', rating: 5 },
|
||||
{ quote: 'Quote two', name: 'Name Two', title: 'Title Two', rating: 4 },
|
||||
{ quote: 'Quote three', name: 'Name Three', title: 'Title Three', rating: 3 },
|
||||
];
|
||||
|
||||
describe('Testimonials.toHtml single-layout export parity', () => {
|
||||
// The editor's "single" layout shows exactly one testimonial (a single
|
||||
// card, no stacked list). Static-parity fix: toHtml exports exactly one
|
||||
// card too (the first testimonial), matching what the editor displays by
|
||||
// default -- not a stacked list of all testimonials, and not a JS carousel
|
||||
// (this codebase's static export has no published-JS interactivity for
|
||||
// this component).
|
||||
test('single layout: exports exactly one testimonial card, not all of them', () => {
|
||||
const { html } = toHtml({ testimonials, layout: 'single' }, '');
|
||||
expect(html).toContain('Name One');
|
||||
expect(html).not.toContain('Name Two');
|
||||
expect(html).not.toContain('Name Three');
|
||||
expect(html).toContain('Quote one');
|
||||
});
|
||||
|
||||
test('single layout: no carousel controls (prev/next/dots) in static export', () => {
|
||||
const { html } = toHtml({ testimonials, layout: 'single' }, '');
|
||||
expect(html).not.toContain('fa-chevron-left');
|
||||
expect(html).not.toContain('fa-chevron-right');
|
||||
});
|
||||
|
||||
test('grid layout: still exports all testimonials (unchanged behavior)', () => {
|
||||
const { html } = toHtml({ testimonials, layout: 'grid' }, '');
|
||||
expect(html).toContain('Name One');
|
||||
expect(html).toContain('Name Two');
|
||||
expect(html).toContain('Name Three');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Testimonials.toHtml decorative star icons (F2.5)', () => {
|
||||
test('star glyphs are aria-hidden', () => {
|
||||
const { html } = toHtml({ testimonials, layout: 'grid' }, '');
|
||||
const stars = html.match(/<i class="fa fa-star[^"]*"[^>]*>/g) || [];
|
||||
expect(stars.length).toBeGreaterThan(0);
|
||||
stars.forEach((tag: string) => expect(tag).toContain('aria-hidden="true"'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('Testimonials.toHtml rating aria-label sink (attacker-controlled `rating`, typed number but unchecked)', () => {
|
||||
test('malicious rating value cannot break out of the star row aria-label attribute', () => {
|
||||
const malicious = [
|
||||
{ quote: 'Q', name: 'N', title: 'T', rating: '5"><script>alert(1)</script>' as any },
|
||||
];
|
||||
const { html } = toHtml({ testimonials: malicious, layout: 'grid' }, '');
|
||||
expect(html).not.toContain('<script>alert(1)</script>');
|
||||
expect(html).not.toContain('5"><script>');
|
||||
});
|
||||
|
||||
test('non-numeric rating falls back to a safe numeric value', () => {
|
||||
const malicious = [
|
||||
{ quote: 'Q', name: 'N', title: 'T', rating: 'not-a-number' as any },
|
||||
];
|
||||
const { html } = toHtml({ testimonials: malicious, layout: 'grid' }, '');
|
||||
expect(html).toMatch(/aria-label="Rating: 0 out of 5"/);
|
||||
});
|
||||
|
||||
test('normal numeric rating still renders correctly', () => {
|
||||
const { html } = toHtml({ testimonials, layout: 'grid' }, '');
|
||||
expect(html).toContain('aria-label="Rating: 5 out of 5"');
|
||||
expect(html).toContain('aria-label="Rating: 4 out of 5"');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Testimonials.craft.props includes the box-model/animation/visibility rollout props', () => {
|
||||
test('animation, animationDelay, and all 3 hideOn* flags are declared (blank/false defaults)', () => {
|
||||
const props = (Testimonials as any).craft.props;
|
||||
expect(props).toHaveProperty('animation', '');
|
||||
expect(props).toHaveProperty('animationDelay', '');
|
||||
expect(props).toHaveProperty('hideOnDesktop', false);
|
||||
expect(props).toHaveProperty('hideOnTablet', false);
|
||||
expect(props).toHaveProperty('hideOnMobile', false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,209 @@
|
||||
import React, { CSSProperties } from 'react';
|
||||
import { useNode, UserComponent } from '@craftjs/core';
|
||||
import { cssPropsToString } from '../../utils/style-helpers';
|
||||
import { escapeHtml, escapeAttr, cssValue } from '../../utils/escape';
|
||||
|
||||
interface Testimonial {
|
||||
quote: string;
|
||||
name: string;
|
||||
title: string;
|
||||
rating: number;
|
||||
}
|
||||
|
||||
interface TestimonialsProps {
|
||||
testimonials?: Testimonial[];
|
||||
layout?: 'grid' | 'single';
|
||||
columns?: number;
|
||||
style?: CSSProperties;
|
||||
cardBg?: string;
|
||||
starColor?: string;
|
||||
anchorId?: string;
|
||||
animation?: string;
|
||||
animationDelay?: string;
|
||||
hideOnDesktop?: boolean;
|
||||
hideOnTablet?: boolean;
|
||||
hideOnMobile?: boolean;
|
||||
}
|
||||
|
||||
const defaultTestimonials: Testimonial[] = [
|
||||
{ quote: 'This product has completely transformed our workflow. Highly recommended for any team.', name: 'Sarah Johnson', title: 'Marketing Director', rating: 5 },
|
||||
{ quote: 'Outstanding support and an incredibly intuitive interface. We saw results from day one.', name: 'Michael Chen', title: 'CTO, TechStart', rating: 5 },
|
||||
{ quote: 'The best investment we have made this year. Simple, powerful, and reliable.', name: 'Emily Rodriguez', title: 'Founder, DesignLab', rating: 4 },
|
||||
];
|
||||
|
||||
function renderStars(count: number, color: string): React.ReactNode {
|
||||
return (
|
||||
<div style={{ display: 'flex', gap: '2px', justifyContent: 'center', marginBottom: '12px' }}>
|
||||
{[1, 2, 3, 4, 5].map((i) => (
|
||||
<i
|
||||
key={i}
|
||||
className={`fa ${i <= count ? 'fa-star' : 'fa-star-o'}`}
|
||||
style={{ color, fontSize: '14px' }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function starsHtml(count: number, color: string): string {
|
||||
// `count` is `Testimonial.rating`, typed `number` but arrives unchecked via
|
||||
// AI update_props / deserialized state -- coerce to a real number before
|
||||
// it's interpolated into the aria-label attribute below (both the loop
|
||||
// comparison and the escapeAttr(String(...)) call are safe against any
|
||||
// non-numeric/garbage value).
|
||||
const safeCount = Number(count) || 0;
|
||||
const stars = [1, 2, 3, 4, 5].map((i) =>
|
||||
`<i class="fa ${i <= safeCount ? 'fa-star' : 'fa-star-o'}" style="color:${color};font-size:14px" aria-hidden="true"></i>`
|
||||
).join('');
|
||||
return `<div style="display:flex;gap:2px;justify-content:center;margin-bottom:12px" role="img" aria-label="${escapeAttr(`Rating: ${safeCount} out of 5`)}">${stars}</div>`;
|
||||
}
|
||||
|
||||
export const Testimonials: UserComponent<TestimonialsProps> = ({
|
||||
testimonials = defaultTestimonials,
|
||||
layout = 'grid',
|
||||
columns = 3,
|
||||
style = {},
|
||||
cardBg = '#f8fafc',
|
||||
starColor = '#f59e0b',
|
||||
anchorId,
|
||||
}) => {
|
||||
const {
|
||||
connectors: { connect, drag },
|
||||
selected,
|
||||
} = useNode((node) => ({
|
||||
selected: node.events.selected,
|
||||
}));
|
||||
|
||||
const cardStyle: CSSProperties = {
|
||||
backgroundColor: cardBg,
|
||||
borderRadius: '12px',
|
||||
padding: '32px 24px',
|
||||
textAlign: 'center',
|
||||
border: '1px solid #e2e8f0',
|
||||
};
|
||||
|
||||
const renderCard = (t: Testimonial, i: number) => (
|
||||
<div key={i} style={cardStyle}>
|
||||
{renderStars(t.rating, starColor)}
|
||||
<p style={{ fontSize: '15px', color: '#374151', lineHeight: '1.7', marginBottom: '16px', fontStyle: 'italic', fontFamily: 'Inter, sans-serif' }}>
|
||||
“{t.quote}”
|
||||
</p>
|
||||
<div style={{ fontWeight: '600', fontSize: '14px', color: '#18181b', fontFamily: 'Inter, sans-serif' }}>{t.name}</div>
|
||||
<div style={{ fontSize: '13px', color: '#64748b', fontFamily: 'Inter, sans-serif' }}>{t.title}</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const items = testimonials.length > 0 ? testimonials : defaultTestimonials;
|
||||
|
||||
return (
|
||||
<section
|
||||
ref={(ref: HTMLElement | null): void => { if (ref) connect(drag(ref)); }}
|
||||
id={anchorId || undefined}
|
||||
style={{
|
||||
padding: '80px 20px',
|
||||
backgroundColor: '#ffffff',
|
||||
outline: selected ? '2px solid #3b82f6' : 'none',
|
||||
...style,
|
||||
}}
|
||||
>
|
||||
<div style={{ maxWidth: '1100px', margin: '0 auto' }}>
|
||||
{layout === 'grid' ? (
|
||||
<div style={{ display: 'grid', gridTemplateColumns: `repeat(${columns}, 1fr)`, gap: '24px' }}>
|
||||
{items.map((t, i) => renderCard(t, i))}
|
||||
</div>
|
||||
) : (
|
||||
// Static single testimonial (parity with the static toHtml export --
|
||||
// no carousel controls, since the published site has no JS for this
|
||||
// component). Always shows the first testimonial.
|
||||
<div style={{ maxWidth: '600px', margin: '0 auto', position: 'relative' }}>
|
||||
{renderCard(items[0], 0)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
/* ---------- Craft config ---------- */
|
||||
|
||||
Testimonials.craft = {
|
||||
displayName: 'Testimonials',
|
||||
props: {
|
||||
testimonials: defaultTestimonials,
|
||||
layout: 'grid',
|
||||
columns: 3,
|
||||
style: { backgroundColor: '#ffffff' },
|
||||
cardBg: '#f8fafc',
|
||||
starColor: '#f59e0b',
|
||||
anchorId: '',
|
||||
animation: '',
|
||||
animationDelay: '',
|
||||
hideOnDesktop: false,
|
||||
hideOnTablet: false,
|
||||
hideOnMobile: false,
|
||||
},
|
||||
rules: {
|
||||
canDrag: () => true,
|
||||
canMoveIn: () => false,
|
||||
canMoveOut: () => true,
|
||||
},
|
||||
};
|
||||
|
||||
/* ---------- HTML export ---------- */
|
||||
|
||||
(Testimonials as any).toHtml = (props: TestimonialsProps, _childrenHtml: string) => {
|
||||
const {
|
||||
testimonials = defaultTestimonials,
|
||||
layout = 'grid',
|
||||
style = {},
|
||||
} = props;
|
||||
// Number() coercion: `columns` is a raw string-interpolation sink into the
|
||||
// grid style attribute below (repeat(${columns},1fr)); Number() of
|
||||
// anything non-numeric collapses safely to NaN instead of breaking out.
|
||||
const columns = Number(props.columns) || 3;
|
||||
// Sanitized -- raw string-interpolation sinks below (cardCss / starsHtml
|
||||
// style attributes).
|
||||
const cardBg = cssValue(props.cardBg) || '#f8fafc';
|
||||
const starColor = cssValue(props.starColor) || '#f59e0b';
|
||||
|
||||
const items = testimonials.length > 0 ? testimonials : defaultTestimonials;
|
||||
|
||||
const sectionStyle = cssPropsToString({
|
||||
padding: '80px 20px',
|
||||
backgroundColor: '#ffffff',
|
||||
...style,
|
||||
});
|
||||
const idAttr = props.anchorId ? ` id="${escapeAttr(props.anchorId)}"` : '';
|
||||
|
||||
const cardCss = `background-color:${cardBg};border-radius:12px;padding:32px 24px;text-align:center;border:1px solid #e2e8f0`;
|
||||
|
||||
const cardHtml = (t: Testimonial): string => `<div style="${cardCss}">
|
||||
${starsHtml(t.rating, starColor)}
|
||||
<p style="font-size:15px;color:#374151;line-height:1.7;margin-bottom:16px;font-style:italic;font-family:Inter,sans-serif">“${escapeHtml(t.quote)}”</p>
|
||||
<div style="font-weight:600;font-size:14px;color:#18181b;font-family:Inter,sans-serif">${escapeHtml(t.name)}</div>
|
||||
<div style="font-size:13px;color:#64748b;font-family:Inter,sans-serif">${escapeHtml(t.title)}</div>
|
||||
</div>`;
|
||||
|
||||
if (layout === 'single') {
|
||||
// Static parity with the editor's single-layout render: exactly ONE
|
||||
// testimonial card (the first), no carousel controls -- the published
|
||||
// export has no JS for this component.
|
||||
return {
|
||||
html: `<section${idAttr}${sectionStyle ? ` style="${sectionStyle}"` : ''}>
|
||||
<div style="max-width:600px;margin:0 auto">
|
||||
${cardHtml(items[0])}
|
||||
</div>
|
||||
</section>`,
|
||||
};
|
||||
}
|
||||
|
||||
const cards = items.map(cardHtml).join('\n ');
|
||||
|
||||
return {
|
||||
html: `<section${idAttr}${sectionStyle ? ` style="${sectionStyle}"` : ''}>
|
||||
<div style="max-width:1100px;margin:0 auto;display:grid;grid-template-columns:repeat(${columns},1fr);gap:24px">
|
||||
${cards}
|
||||
</div>
|
||||
</section>`,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,95 @@
|
||||
import { CSSProperties } from 'react';
|
||||
import { escapeHtml, escapeAttr, safeUrl, cssValue } from '../../utils/escape';
|
||||
|
||||
export type CtaVariant = 'primary' | 'outline' | 'ghost';
|
||||
|
||||
export interface CtaButton {
|
||||
text: string;
|
||||
href: string;
|
||||
variant?: CtaVariant;
|
||||
target?: '_blank';
|
||||
}
|
||||
|
||||
export interface CtaStyleDefaults {
|
||||
primaryBg: string;
|
||||
primaryText: string;
|
||||
outlineText: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the effective list of CTAs for a section, falling back to legacy
|
||||
* primary/secondary props when ctas[] is absent. New sections write ctas[]
|
||||
* directly; old sections keep rendering until the user touches the settings.
|
||||
*/
|
||||
export function normalizeCtas(props: {
|
||||
ctas?: CtaButton[];
|
||||
buttonText?: string;
|
||||
buttonHref?: string;
|
||||
secondaryButtonText?: string;
|
||||
secondaryButtonHref?: string;
|
||||
}): CtaButton[] {
|
||||
if (Array.isArray(props.ctas) && props.ctas.length > 0) {
|
||||
return props.ctas.filter((c) => c && (c.text || c.href));
|
||||
}
|
||||
const legacy: CtaButton[] = [];
|
||||
if (props.buttonText) legacy.push({ text: props.buttonText, href: props.buttonHref || '#', variant: 'primary' });
|
||||
if (props.secondaryButtonText) legacy.push({ text: props.secondaryButtonText, href: props.secondaryButtonHref || '#', variant: 'outline' });
|
||||
return legacy;
|
||||
}
|
||||
|
||||
export function ctaInlineStyle(cta: CtaButton, defaults: CtaStyleDefaults): CSSProperties {
|
||||
const variant = cta.variant || 'primary';
|
||||
switch (variant) {
|
||||
case 'outline':
|
||||
return {
|
||||
display: 'inline-block', padding: '14px 36px',
|
||||
backgroundColor: 'transparent', color: defaults.outlineText,
|
||||
textDecoration: 'none', borderRadius: '8px',
|
||||
fontWeight: 600, fontSize: '16px',
|
||||
border: `2px solid ${defaults.outlineText}`,
|
||||
};
|
||||
case 'ghost':
|
||||
return {
|
||||
display: 'inline-block', padding: '14px 24px',
|
||||
backgroundColor: 'transparent', color: defaults.outlineText,
|
||||
textDecoration: 'underline', borderRadius: '8px',
|
||||
fontWeight: 600, fontSize: '16px',
|
||||
};
|
||||
case 'primary':
|
||||
default:
|
||||
return {
|
||||
display: 'inline-block', padding: '14px 36px',
|
||||
backgroundColor: defaults.primaryBg, color: defaults.primaryText,
|
||||
textDecoration: 'none', borderRadius: '8px',
|
||||
fontWeight: 600, fontSize: '16px',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function ctaCssString(cta: CtaButton, defaults: CtaStyleDefaults): string {
|
||||
const variant = cta.variant || 'primary';
|
||||
// Sanitized -- these are raw string-interpolation sinks into style="...".
|
||||
// Callers pass user-controlled design-token colors (e.g. HeroSimple's
|
||||
// buttonBgColor/buttonTextColor/textColor) through CtaStyleDefaults, so
|
||||
// sanitize once here rather than at every call site.
|
||||
const outlineText = cssValue(defaults.outlineText) || '#000000';
|
||||
const primaryBg = cssValue(defaults.primaryBg) || '#000000';
|
||||
const primaryText = cssValue(defaults.primaryText) || '#ffffff';
|
||||
switch (variant) {
|
||||
case 'outline':
|
||||
return `display:inline-block;padding:14px 36px;background-color:transparent;color:${outlineText};text-decoration:none;border-radius:8px;font-weight:600;font-size:16px;border:2px solid ${outlineText}`;
|
||||
case 'ghost':
|
||||
return `display:inline-block;padding:14px 24px;background-color:transparent;color:${outlineText};text-decoration:underline;border-radius:8px;font-weight:600;font-size:16px`;
|
||||
case 'primary':
|
||||
default:
|
||||
return `display:inline-block;padding:14px 36px;background-color:${primaryBg};color:${primaryText};text-decoration:none;border-radius:8px;font-weight:600;font-size:16px`;
|
||||
}
|
||||
}
|
||||
|
||||
export function ctasToHtml(ctas: CtaButton[], defaults: CtaStyleDefaults): string {
|
||||
return ctas.map((c) => {
|
||||
const target = c.target === '_blank' ? ' target="_blank" rel="noopener noreferrer"' : '';
|
||||
return `<a href="${escapeAttr(safeUrl(c.href || '#'))}"${target} style="${ctaCssString(c, defaults)}">${escapeHtml(c.text || '')}</a>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
import { describe, test, expect } from 'vitest';
|
||||
import { ButtonLink } from './basic/ButtonLink';
|
||||
import { Icon } from './basic/Icon';
|
||||
import { SocialLinks } from './basic/SocialLinks';
|
||||
import { Logo } from './basic/Logo';
|
||||
import { Menu } from './basic/Menu';
|
||||
import { Navbar } from './basic/Navbar';
|
||||
import { ContentSlider } from './sections/ContentSlider';
|
||||
import { FeaturesGrid } from './sections/FeaturesGrid';
|
||||
import { PricingTable } from './sections/PricingTable';
|
||||
import { ImageBlock } from './media/ImageBlock';
|
||||
import { VideoBlock } from './media/VideoBlock';
|
||||
import { Gallery } from './sections/Gallery';
|
||||
import { BackgroundSection } from './layout/BackgroundSection';
|
||||
import { HeroSimple } from './sections/HeroSimple';
|
||||
import { CallToAction } from './sections/CallToAction';
|
||||
import { MapEmbed } from './media/MapEmbed';
|
||||
import { FormContainer } from './forms/FormContainer';
|
||||
|
||||
const XSS = 'javascript:alert(1)';
|
||||
const QUOTE_BREAKOUT = 'x" onerror="alert(1)"';
|
||||
|
||||
function toHtmlOf(Component: any) {
|
||||
return Component.toHtml as (props: any, childrenHtml: string) => { html: string };
|
||||
}
|
||||
|
||||
describe('A3: exported URLs are wrapped in safeUrl + escapeAttr', () => {
|
||||
test('ButtonLink href', () => {
|
||||
const html = toHtmlOf(ButtonLink)({ href: XSS, text: 'Go' }, '').html;
|
||||
expect(html).not.toContain('javascript:');
|
||||
const html2 = toHtmlOf(ButtonLink)({ href: QUOTE_BREAKOUT, text: 'Go' }, '').html;
|
||||
expect(html2).not.toContain('onerror="alert(1)"');
|
||||
});
|
||||
|
||||
test('Icon href (link), class is escaped but NOT safeUrl-filtered', () => {
|
||||
const html = toHtmlOf(Icon)({ link: XSS, icon: 'fa-star' }, '').html;
|
||||
expect(html).not.toContain('javascript:');
|
||||
const html2 = toHtmlOf(Icon)({ link: QUOTE_BREAKOUT, icon: 'fa-star' }, '').html;
|
||||
expect(html2).not.toContain('onerror="alert(1)"');
|
||||
// class attribute is a CSS class, not a URL -- still escaped for attr safety
|
||||
const html3 = toHtmlOf(Icon)({ icon: 'fa-star" onerror="alert(1)' }, '').html;
|
||||
expect(html3).not.toContain('onerror="alert(1)"');
|
||||
});
|
||||
|
||||
test('SocialLinks href', () => {
|
||||
const html = toHtmlOf(SocialLinks)({ links: [{ platform: 'facebook', url: XSS }] }, '').html;
|
||||
expect(html).not.toContain('javascript:');
|
||||
const html2 = toHtmlOf(SocialLinks)({ links: [{ platform: 'facebook', url: QUOTE_BREAKOUT }] }, '').html;
|
||||
expect(html2).not.toContain('onerror="alert(1)"');
|
||||
});
|
||||
|
||||
test('Logo href + image src', () => {
|
||||
const html = toHtmlOf(Logo)({ href: XSS, type: 'text', text: 'Site' }, '').html;
|
||||
expect(html).not.toContain('javascript:');
|
||||
const html2 = toHtmlOf(Logo)({ type: 'image', imageSrc: XSS }, '').html;
|
||||
expect(html2).not.toContain('javascript:');
|
||||
const html3 = toHtmlOf(Logo)({ type: 'image', imageSrc: QUOTE_BREAKOUT }, '').html;
|
||||
expect(html3).not.toContain('onerror="alert(1)"');
|
||||
});
|
||||
|
||||
test('Menu link href', () => {
|
||||
const html = toHtmlOf(Menu)({ links: [{ text: 'x', href: XSS }] }, '').html;
|
||||
expect(html).not.toContain('javascript:');
|
||||
const html2 = toHtmlOf(Menu)({ links: [{ text: 'x', href: QUOTE_BREAKOUT }] }, '').html;
|
||||
expect(html2).not.toContain('onerror="alert(1)"');
|
||||
});
|
||||
|
||||
test('Navbar logo href, logo image src, link hrefs', () => {
|
||||
const html = toHtmlOf(Navbar)({ logoUrl: XSS, logoType: 'text', logoText: 'Site', links: [{ text: 'x', href: XSS }] }, '').html;
|
||||
expect(html).not.toContain('javascript:');
|
||||
const html2 = toHtmlOf(Navbar)({ logoType: 'image', logoImage: XSS }, '').html;
|
||||
expect(html2).not.toContain('javascript:');
|
||||
const html3 = toHtmlOf(Navbar)({ logoUrl: QUOTE_BREAKOUT, links: [{ text: 'x', href: QUOTE_BREAKOUT }] }, '').html;
|
||||
expect(html3).not.toContain('onerror="alert(1)"');
|
||||
});
|
||||
|
||||
test('ContentSlider button href + slide image src (background url)', () => {
|
||||
const html = toHtmlOf(ContentSlider)({ slides: [{ buttonText: 'Go', buttonHref: XSS, imageSrc: '' }] }, '').html;
|
||||
expect(html).not.toContain('javascript:');
|
||||
const html2 = toHtmlOf(ContentSlider)({ slides: [{ imageSrc: XSS }] }, '').html;
|
||||
expect(html2).not.toContain('javascript:');
|
||||
const html3 = toHtmlOf(ContentSlider)({ slides: [{ buttonText: 'Go', buttonHref: QUOTE_BREAKOUT }] }, '').html;
|
||||
expect(html3).not.toContain('onerror="alert(1)"');
|
||||
});
|
||||
|
||||
test('FeaturesGrid image src + button url', () => {
|
||||
const html = toHtmlOf(FeaturesGrid)({ features: [{ title: 't', description: 'd', image: XSS }] }, '').html;
|
||||
expect(html).not.toContain('javascript:');
|
||||
const html2 = toHtmlOf(FeaturesGrid)({ features: [{ title: 't', description: 'd', buttonText: 'Go', buttonUrl: XSS }] }, '').html;
|
||||
expect(html2).not.toContain('javascript:');
|
||||
});
|
||||
|
||||
test('PricingTable button href', () => {
|
||||
const html = toHtmlOf(PricingTable)({ plans: [{ name: 'p', price: '$1', period: '/mo', features: [], buttonText: 'Buy', buttonHref: XSS }] }, '').html;
|
||||
expect(html).not.toContain('javascript:');
|
||||
const html2 = toHtmlOf(PricingTable)({ plans: [{ name: 'p', price: '$1', period: '/mo', features: [], buttonText: 'Buy', buttonHref: QUOTE_BREAKOUT }] }, '').html;
|
||||
expect(html2).not.toContain('onerror="alert(1)"');
|
||||
});
|
||||
|
||||
test('ImageBlock img src', () => {
|
||||
const html = toHtmlOf(ImageBlock)({ src: XSS }, '').html;
|
||||
expect(html).not.toContain('javascript:');
|
||||
const html2 = toHtmlOf(ImageBlock)({ src: QUOTE_BREAKOUT }, '').html;
|
||||
expect(html2).not.toContain('onerror="alert(1)"');
|
||||
});
|
||||
|
||||
test('VideoBlock direct-file src is safeUrl-filtered on final emitted src', () => {
|
||||
// matches the .mp4 extension sniff in detectVideoType but carries a javascript: scheme
|
||||
const html = toHtmlOf(VideoBlock)({ videoUrl: 'javascript:alert(1)//x.mp4' }, '').html;
|
||||
expect(html).not.toContain('javascript:');
|
||||
const html2 = toHtmlOf(VideoBlock)({ videoUrl: '"><script>alert(1)</script>.mp4' }, '').html;
|
||||
expect(html2).not.toContain('<script>alert(1)</script>');
|
||||
});
|
||||
|
||||
test('VideoBlock youtube embed still works after safeUrl pass', () => {
|
||||
const html = toHtmlOf(VideoBlock)({ videoUrl: 'https://www.youtube.com/watch?v=abc123' }, '').html;
|
||||
expect(html).toContain('https://www.youtube.com/embed/abc123');
|
||||
});
|
||||
|
||||
test('Gallery img src', () => {
|
||||
const html = toHtmlOf(Gallery)({ images: [{ src: XSS, alt: 'a' }] }, '').html;
|
||||
expect(html).not.toContain('javascript:');
|
||||
const html2 = toHtmlOf(Gallery)({ images: [{ src: QUOTE_BREAKOUT, alt: 'a' }] }, '').html;
|
||||
expect(html2).not.toContain('onerror="alert(1)"');
|
||||
});
|
||||
|
||||
test('BackgroundSection bg image url()', () => {
|
||||
const html = toHtmlOf(BackgroundSection)({ bgImage: XSS }, '').html;
|
||||
expect(html).not.toContain('javascript:');
|
||||
});
|
||||
|
||||
test('HeroSimple bg image url() and bg video src', () => {
|
||||
const html = toHtmlOf(HeroSimple)({ bgType: 'image', bgImage: XSS }, '').html;
|
||||
expect(html).not.toContain('javascript:');
|
||||
const html2 = toHtmlOf(HeroSimple)({ bgType: 'video', bgVideo: XSS }, '').html;
|
||||
expect(html2).not.toContain('javascript:');
|
||||
const html3 = toHtmlOf(HeroSimple)({ bgType: 'video', bgVideo: QUOTE_BREAKOUT }, '').html;
|
||||
expect(html3).not.toContain('onerror="alert(1)"');
|
||||
});
|
||||
|
||||
test('CallToAction bg image url()', () => {
|
||||
const html = toHtmlOf(CallToAction)({ bgType: 'image', bgValue: XSS }, '').html;
|
||||
expect(html).not.toContain('javascript:');
|
||||
});
|
||||
|
||||
test('MapEmbed iframe src (found beyond the brief-listed sites via grep sweep)', () => {
|
||||
const html = toHtmlOf(MapEmbed)({ address: 'New York, NY', zoom: 14 }, '').html;
|
||||
expect(html).toContain('maps.google.com');
|
||||
expect(html).not.toContain('javascript:');
|
||||
});
|
||||
|
||||
test('FormContainer legacy form action is safeUrl-filtered (found via grep sweep)', () => {
|
||||
const html = toHtmlOf(FormContainer)({ action: XSS, method: 'GET' }, '').html;
|
||||
expect(html).not.toContain('javascript:');
|
||||
// non-relay legacy path (no recipientEmail) still works normally
|
||||
const html2 = toHtmlOf(FormContainer)({ action: '/legacy', method: 'POST' }, '').html;
|
||||
expect(html2).toContain('action="/legacy"');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,161 @@
|
||||
export const TEXT_COLORS = [
|
||||
{ label: 'Dark', value: '#1f2937' },
|
||||
{ label: 'Medium', value: '#374151' },
|
||||
{ label: 'Light', value: '#6b7280' },
|
||||
{ label: 'White', value: '#ffffff' },
|
||||
{ label: 'Blue', value: '#3b82f6' },
|
||||
{ label: 'Green', value: '#10b981' },
|
||||
{ label: 'Red', value: '#ef4444' },
|
||||
{ label: 'Orange', value: '#f59e0b' },
|
||||
];
|
||||
|
||||
export const BG_COLORS = [
|
||||
{ label: 'White', value: '#ffffff' },
|
||||
{ label: 'Off-white', value: '#f9fafb' },
|
||||
{ label: 'Dark', value: '#1f2937' },
|
||||
{ label: 'Darker', value: '#111827' },
|
||||
{ label: 'Blue', value: '#3b82f6' },
|
||||
{ label: 'Green', value: '#10b981' },
|
||||
{ label: 'Purple', value: '#8b5cf6' },
|
||||
{ label: 'Pink', value: '#ec4899' },
|
||||
];
|
||||
|
||||
export const FONT_FAMILIES = [
|
||||
{ label: 'Inter', value: 'Inter, sans-serif' },
|
||||
{ label: 'Roboto', value: 'Roboto, sans-serif' },
|
||||
{ label: 'Open Sans', value: 'Open Sans, sans-serif' },
|
||||
{ label: 'Poppins', value: 'Poppins, sans-serif' },
|
||||
{ label: 'Montserrat', value: 'Montserrat, sans-serif' },
|
||||
{ label: 'Playfair', value: 'Playfair Display, serif' },
|
||||
{ label: 'Merriweather', value: 'Merriweather, serif' },
|
||||
{ label: 'Source Code', value: 'Source Code Pro, monospace' },
|
||||
];
|
||||
|
||||
export const TEXT_SIZES = [
|
||||
{ label: 'XS', value: '12px' },
|
||||
{ label: 'S', value: '14px' },
|
||||
{ label: 'M', value: '16px' },
|
||||
{ label: 'L', value: '20px' },
|
||||
{ label: 'XL', value: '24px' },
|
||||
{ label: '2XL', value: '32px' },
|
||||
];
|
||||
|
||||
export const FONT_WEIGHTS = [
|
||||
{ label: 'Light', value: '300' },
|
||||
{ label: 'Normal', value: '400' },
|
||||
{ label: 'Medium', value: '500' },
|
||||
{ label: 'Semi', value: '600' },
|
||||
{ label: 'Bold', value: '700' },
|
||||
];
|
||||
|
||||
export const SPACING_PRESETS = [
|
||||
{ label: 'None', value: '0' },
|
||||
{ label: 'S', value: '8px' },
|
||||
{ label: 'M', value: '16px' },
|
||||
{ label: 'L', value: '24px' },
|
||||
{ label: 'XL', value: '32px' },
|
||||
];
|
||||
|
||||
export const RADIUS_PRESETS = [
|
||||
{ label: 'None', value: '0' },
|
||||
{ label: 'S', value: '8px' },
|
||||
{ label: 'M', value: '16px' },
|
||||
{ label: 'L', value: '32px' },
|
||||
{ label: 'Full', value: '9999px' },
|
||||
];
|
||||
|
||||
// Images need a larger radius scale than buttons/containers to be visually
|
||||
// distinct on typical photo dimensions.
|
||||
export const IMAGE_RADIUS_PRESETS = [
|
||||
{ label: 'None', value: '0' },
|
||||
{ label: 'S', value: '24px' },
|
||||
{ label: 'M', value: '48px' },
|
||||
{ label: 'L', value: '96px' },
|
||||
{ label: 'Full', value: '9999px' },
|
||||
];
|
||||
|
||||
export const GRADIENTS = [
|
||||
{ label: 'Purple Dream', value: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)' },
|
||||
{ label: 'Pink Sunset', value: 'linear-gradient(135deg, #f093fb 0%, #f5576c 100%)' },
|
||||
{ label: 'Ocean Blue', value: 'linear-gradient(135deg, #4facfe 0%, #00f2fe 100%)' },
|
||||
{ label: 'Green Teal', value: 'linear-gradient(135deg, #43e97b 0%, #38f9d7 100%)' },
|
||||
{ label: 'Warm Sunrise', value: 'linear-gradient(135deg, #fa709a 0%, #fee140 100%)' },
|
||||
{ label: 'Soft Pastel', value: 'linear-gradient(135deg, #a18cd1 0%, #fbc2eb 100%)' },
|
||||
{ label: 'Peach', value: 'linear-gradient(135deg, #ffecd2 0%, #fcb69f 100%)' },
|
||||
{ label: 'Warm Sand', value: 'linear-gradient(135deg, #f5af19 0%, #f12711 100%)' },
|
||||
{ label: 'Dark Purple', value: 'linear-gradient(135deg, #1a1a2e 0%, #16213e 100%)' },
|
||||
{ label: 'Dark Blue', value: 'linear-gradient(135deg, #0f172a 0%, #1e3a5f 100%)' },
|
||||
{ label: 'Dark Gray', value: 'linear-gradient(135deg, #1f2937 0%, #111827 100%)' },
|
||||
{ label: 'None', value: 'none' },
|
||||
];
|
||||
|
||||
export const DEVICE_WIDTHS: Record<string, string> = {
|
||||
desktop: '100%',
|
||||
tablet: '768px',
|
||||
mobile: '375px',
|
||||
};
|
||||
|
||||
/* ---------- ENH-Foundation shared presets ----------
|
||||
Consumed by the reusable StylePanel controls in
|
||||
src/panels/right/styles/shared.tsx (SizeControl, AspectRatioControl,
|
||||
BorderControl, etc). Kept in the `{ label, value }` shape used throughout
|
||||
this file so they drop straight into PresetButtonGrid / ColorSwatchGrid. */
|
||||
|
||||
// Width/height preset row used by SizeControl (image + video sizing).
|
||||
export const SIZE_PRESETS = [
|
||||
{ label: '25%', value: '25%' },
|
||||
{ label: '50%', value: '50%' },
|
||||
{ label: '75%', value: '75%' },
|
||||
{ label: '100%', value: '100%' },
|
||||
{ label: 'Auto', value: 'auto' },
|
||||
{ label: 'Full', value: '100vw' },
|
||||
];
|
||||
|
||||
// CSS `aspect-ratio` values. 'Original' (empty string) clears the property.
|
||||
export const ASPECT_RATIOS = [
|
||||
{ label: 'Original', value: '' },
|
||||
{ label: '1:1', value: '1 / 1' },
|
||||
{ label: '4:3', value: '4 / 3' },
|
||||
{ label: '3:2', value: '3 / 2' },
|
||||
{ label: '16:9', value: '16 / 9' },
|
||||
{ label: '4:5', value: '4 / 5' },
|
||||
{ label: '9:16', value: '9 / 16' },
|
||||
];
|
||||
|
||||
// box-shadow presets; 'Colored' is the color-aware option (accent-tinted).
|
||||
export const SHADOW_PRESETS = [
|
||||
{ label: 'None', value: 'none' },
|
||||
{ label: 'S', value: '0 1px 2px rgba(0,0,0,0.08)' },
|
||||
{ label: 'M', value: '0 4px 8px rgba(0,0,0,0.12)' },
|
||||
{ label: 'L', value: '0 10px 24px rgba(0,0,0,0.18)' },
|
||||
{ label: 'Colored', value: '0 8px 20px rgba(59,130,246,0.35)' },
|
||||
];
|
||||
|
||||
export const LINE_HEIGHTS = [
|
||||
{ label: 'Tight', value: '1.1' },
|
||||
{ label: 'Snug', value: '1.25' },
|
||||
{ label: 'Normal', value: '1.5' },
|
||||
{ label: 'Relaxed', value: '1.75' },
|
||||
{ label: 'Loose', value: '2' },
|
||||
];
|
||||
|
||||
export const LETTER_SPACINGS = [
|
||||
{ label: 'Tight', value: '-0.02em' },
|
||||
{ label: 'Normal', value: 'normal' },
|
||||
{ label: 'Wide', value: '0.05em' },
|
||||
{ label: 'Wider', value: '0.1em' },
|
||||
];
|
||||
|
||||
export const OBJECT_FIT = [
|
||||
{ label: 'Cover', value: 'cover' },
|
||||
{ label: 'Contain', value: 'contain' },
|
||||
{ label: 'Fill', value: 'fill' },
|
||||
{ label: 'None', value: 'none' },
|
||||
];
|
||||
|
||||
export const BORDER_STYLES = [
|
||||
{ label: 'None', value: 'none' },
|
||||
{ label: 'Solid', value: 'solid' },
|
||||
{ label: 'Dashed', value: 'dashed' },
|
||||
{ label: 'Dotted', value: 'dotted' },
|
||||
];
|
||||
@@ -0,0 +1,238 @@
|
||||
import React, { useMemo, useRef, useEffect } from 'react';
|
||||
import { Frame, Element, useEditor } from '@craftjs/core';
|
||||
import { Container } from '../components/layout/Container';
|
||||
import { usePages } from '../state/PageContext';
|
||||
import { DeviceMode } from '../types';
|
||||
import { DEVICE_WIDTHS } from '../constants/presets';
|
||||
import { exportBodyHtml } from '../utils/html-export';
|
||||
import { useIsMobile } from '../hooks/useIsMobile';
|
||||
import { useMobileChrome } from '../state/MobileChromeContext';
|
||||
|
||||
interface CanvasProps {
|
||||
device: DeviceMode;
|
||||
/** Item 10: when false, applies `.guides-off` to hide the dashed drop-target guides. */
|
||||
showGuides: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the actual header/footer content from the Craft.js state as a
|
||||
* non-interactive preview. This is the user's own authored content.
|
||||
*/
|
||||
const ZonePreview: React.FC<{ craftState: string | null; zone: 'header' | 'footer' }> = ({
|
||||
craftState,
|
||||
zone,
|
||||
}) => {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const renderedHtml = useMemo(() => {
|
||||
if (!craftState) return null;
|
||||
try {
|
||||
const result = exportBodyHtml(craftState);
|
||||
return result.html || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}, [craftState]);
|
||||
|
||||
// Set the rendered HTML into the container via ref (user-authored content)
|
||||
useEffect(() => {
|
||||
if (containerRef.current && renderedHtml) {
|
||||
containerRef.current.textContent = '';
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.innerHTML = renderedHtml; // user's own site content
|
||||
while (wrapper.firstChild) {
|
||||
containerRef.current.appendChild(wrapper.firstChild);
|
||||
}
|
||||
}
|
||||
}, [renderedHtml]);
|
||||
|
||||
if (!renderedHtml) {
|
||||
return (
|
||||
<div
|
||||
data-zone-preview={zone}
|
||||
style={{
|
||||
width: '100%',
|
||||
// Slim hint bar, not a content-height band — an empty zone should not
|
||||
// read as a stray spacer between the page and the header/footer.
|
||||
padding: '5px 12px',
|
||||
backgroundColor: zone === 'header' ? '#ffffff' : '#0f172a',
|
||||
color: zone === 'header' ? '#9ca3af' : '#64748b',
|
||||
textAlign: 'center',
|
||||
fontSize: 11,
|
||||
fontStyle: 'italic',
|
||||
borderBottom: zone === 'header' ? '1px dashed rgba(148,163,184,0.25)' : 'none',
|
||||
borderTop: zone === 'footer' ? '1px dashed rgba(148,163,184,0.25)' : 'none',
|
||||
position: 'relative',
|
||||
pointerEvents: 'none',
|
||||
userSelect: 'none',
|
||||
}}
|
||||
>
|
||||
{zone === 'header'
|
||||
? 'Header (empty -- click Edit Header in Pages tab)'
|
||||
: 'Footer (empty -- click Edit Footer in Pages tab)'}
|
||||
<div style={{
|
||||
position: 'absolute', top: 2, right: 6,
|
||||
fontSize: 9, fontWeight: 700, textTransform: 'uppercase', letterSpacing: '0.5px',
|
||||
color: '#f59e0b', background: 'rgba(245,158,11,0.12)', padding: '1px 5px', borderRadius: 3,
|
||||
}}>
|
||||
{zone}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
data-zone-preview={zone}
|
||||
className="zone-preview-sep"
|
||||
style={{
|
||||
width: '100%',
|
||||
position: 'relative',
|
||||
pointerEvents: 'none',
|
||||
userSelect: 'none',
|
||||
borderBottom: zone === 'header' ? '1px dashed rgba(148,163,184,0.25)' : 'none',
|
||||
borderTop: zone === 'footer' ? '1px dashed rgba(148,163,184,0.25)' : 'none',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* First-run hint shown over the canvas drop area once the current page's
|
||||
* root node exists and has no children yet. Hidden the instant something
|
||||
* is dropped in, and while a drag is in progress (so it never fights the
|
||||
* drop-target UI). `pointer-events: none` (see .empty-canvas-hint in
|
||||
* editor.css) keeps it from intercepting clicks/drops meant for the
|
||||
* underlying empty canvas.
|
||||
*/
|
||||
export const EmptyCanvasHint: React.FC = () => {
|
||||
const { isEmpty, isDragging } = useEditor((state) => {
|
||||
const root = state.nodes['ROOT'];
|
||||
return {
|
||||
isEmpty: !!root && root.data.nodes.length === 0,
|
||||
isDragging: state.events.dragged.size > 0,
|
||||
};
|
||||
});
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
if (!isEmpty || isDragging) return null;
|
||||
|
||||
return (
|
||||
<div className="empty-canvas-hint">
|
||||
<i className="fa fa-cubes" aria-hidden />
|
||||
<span>
|
||||
{isMobile
|
||||
? <>Tap <strong>Blocks</strong> below to add content, or pick a Template to start.</>
|
||||
: 'Drag blocks from the left panel, or pick a Template to start.'}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const Canvas: React.FC<CanvasProps> = ({ device, showGuides }) => {
|
||||
const width = DEVICE_WIDTHS[device];
|
||||
const { isEditingHeader, isEditingFooter, headerPage, footerPage } = usePages();
|
||||
|
||||
const isEditingRegularPage = !isEditingHeader && !isEditingFooter;
|
||||
|
||||
// Fast-follow item 4: while `MobileSelectionToolbar` is up (fixed, ~53px
|
||||
// tall, just above the tab bar), it covers whatever content was at the
|
||||
// very bottom of the canvas's own scroll area -- on a short page, the
|
||||
// last section could sit permanently under the toolbar with no way to
|
||||
// scroll past it. Mirrors that toolbar's own visibility condition
|
||||
// (`selectedId && activeSheet === null`) exactly, computed independently
|
||||
// here since Canvas has no other reason to depend on the toolbar
|
||||
// component itself.
|
||||
const isMobile = useIsMobile();
|
||||
const { activeSheet } = useMobileChrome();
|
||||
const { selectedId } = useEditor((state) => {
|
||||
const selected = state.events.selected;
|
||||
const id = selected && selected.size > 0 ? (Array.from(selected)[0] as string) : null;
|
||||
return { selectedId: id && id !== 'ROOT' ? id : null };
|
||||
});
|
||||
const mobileToolbarVisible = isMobile && !!selectedId && activeSheet === null;
|
||||
|
||||
// Fast-follow item 3: `MobileSelectionToolbar`'s "Style" tap scrolls the
|
||||
// selected node up towards the top of the canvas so it stays visible
|
||||
// above the Styles sheet (~65dvh tall) -- but that scroll is still bound
|
||||
// by the canvas's own natural scroll range. For a selected node near the
|
||||
// END of the content (very plausibly the last section on the page --
|
||||
// exactly the kind of node someone just added/duplicated and wants to
|
||||
// style), there may not be enough scrollable distance below it to bring
|
||||
// its top all the way up to the visible band above the sheet; the browser
|
||||
// simply clamps at its existing max scrollTop, leaving the node's top
|
||||
// stuck behind the sheet with nothing anyone can do about it (verified
|
||||
// live: the last section of a page landed under the sheet even after the
|
||||
// scroll-into-view ran). Pad the canvas with a full extra viewport's
|
||||
// worth of scroll room while the sheet is open with a selection, exactly
|
||||
// as `has-mobile-selection-toolbar` (item 4) already pads it for the
|
||||
// fixed toolbar -- generous enough that ANY node, including the very
|
||||
// last one, can always be scrolled with its top reaching the very top of
|
||||
// the canvas (comfortably within the "top ~30%" target).
|
||||
const mobileStylesSheetPad = isMobile && !!selectedId && activeSheet === 'styles';
|
||||
|
||||
const frameStyle = isEditingHeader
|
||||
? { minHeight: '60px', backgroundColor: '#ffffff', padding: '12px 24px', display: 'flex', alignItems: 'center' }
|
||||
: isEditingFooter
|
||||
? { minHeight: '60px', backgroundColor: '#0f172a', color: '#94a3b8', padding: '40px 24px', textAlign: 'center' as const }
|
||||
: { minHeight: '100vh', backgroundColor: '#ffffff' };
|
||||
|
||||
const frameTag = isEditingHeader ? 'header' : isEditingFooter ? 'footer' : 'div';
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`editor-canvas${mobileToolbarVisible ? ' has-mobile-selection-toolbar' : ''}${
|
||||
mobileStylesSheetPad ? ' has-mobile-styles-sheet' : ''
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`canvas-device-frame${showGuides ? '' : ' guides-off'}`}
|
||||
style={{
|
||||
width,
|
||||
maxWidth: '100%',
|
||||
margin: '0 auto',
|
||||
transition: 'width 0.3s ease',
|
||||
minHeight: '100%',
|
||||
}}
|
||||
>
|
||||
{(isEditingHeader || isEditingFooter) && (
|
||||
<div style={{
|
||||
background: 'rgba(245, 158, 11, 0.1)',
|
||||
borderBottom: '1px solid rgba(245, 158, 11, 0.3)',
|
||||
padding: '6px 12px',
|
||||
fontSize: 11,
|
||||
fontWeight: 600,
|
||||
color: '#f59e0b',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
}}>
|
||||
<i className={`fa ${isEditingHeader ? 'fa-window-maximize' : 'fa-window-minimize'}`} />
|
||||
Editing {isEditingHeader ? 'Header' : 'Footer'} -- This content will appear on all pages
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isEditingRegularPage && (
|
||||
<ZonePreview craftState={headerPage.craftState} zone="header" />
|
||||
)}
|
||||
|
||||
<div style={{ position: 'relative' }}>
|
||||
<Frame>
|
||||
<Element
|
||||
is={Container}
|
||||
canvas
|
||||
tag={frameTag}
|
||||
style={frameStyle}
|
||||
/>
|
||||
</Frame>
|
||||
{isEditingRegularPage && <EmptyCanvasHint />}
|
||||
</div>
|
||||
|
||||
{isEditingRegularPage && (
|
||||
<ZonePreview craftState={footerPage.craftState} zone="footer" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,112 @@
|
||||
import React, { useState, useCallback, useEffect, useRef } from 'react';
|
||||
import { useEditor } from '@craftjs/core';
|
||||
import { TopBar } from '../panels/topbar/TopBar';
|
||||
import { LeftPanel } from '../panels/left/LeftPanel';
|
||||
import { RightPanel } from '../panels/right/RightPanel';
|
||||
import { MobilePanelBar } from '../panels/mobile/MobilePanelBar';
|
||||
import { MobileSelectionToolbar } from '../panels/mobile/MobileSelectionToolbar';
|
||||
import { Canvas } from './Canvas';
|
||||
import { ContextMenu } from '../panels/context-menu/ContextMenu';
|
||||
import { useContextMenu } from '../hooks/useContextMenu';
|
||||
import { useKeyboardShortcuts } from '../hooks/useKeyboardShortcuts';
|
||||
import { useIsMobile } from '../hooks/useIsMobile';
|
||||
import { MobileChromeProvider } from '../state/MobileChromeContext';
|
||||
import { DeviceMode } from '../types';
|
||||
|
||||
const SHOW_GUIDES_STORAGE_KEY = 'craft-show-guides';
|
||||
|
||||
function loadShowGuides(): boolean {
|
||||
try {
|
||||
const stored = window.localStorage.getItem(SHOW_GUIDES_STORAGE_KEY);
|
||||
return stored === null ? true : stored === '1';
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
export const EditorShell: React.FC = () => {
|
||||
const isMobile = useIsMobile();
|
||||
const [device, setDevice] = useState<DeviceMode>('desktop');
|
||||
// Phase A: default the canvas to the "mobile" preview width the first
|
||||
// time we detect a mobile viewport, so the frame fits without the user
|
||||
// having to reach for the device switcher (now tucked in TopBar's
|
||||
// overflow menu on mobile). Only fires once, and only if the device is
|
||||
// still at its initial default -- it must not fight a device the user
|
||||
// has already picked (e.g. from the overflow menu) on a later re-render.
|
||||
const mobileDeviceAppliedRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (isMobile && !mobileDeviceAppliedRef.current) {
|
||||
mobileDeviceAppliedRef.current = true;
|
||||
setDevice((current) => (current === 'desktop' ? 'mobile' : current));
|
||||
}
|
||||
}, [isMobile]);
|
||||
// Item 10: canvas dashed "guide" outlines toggle -- default ON, persisted
|
||||
// so the choice survives a reload. Lifted here (rather than owned by
|
||||
// TopBar or Canvas alone) because the toggle button lives in TopBar but
|
||||
// the `.guides-off` class it drives is applied to Canvas's
|
||||
// `.canvas-device-frame`, mirroring how `device` is already lifted for
|
||||
// the same reason.
|
||||
const [showGuides, setShowGuidesState] = useState<boolean>(loadShowGuides);
|
||||
const setShowGuides = useCallback((next: boolean) => {
|
||||
setShowGuidesState(next);
|
||||
try {
|
||||
window.localStorage.setItem(SHOW_GUIDES_STORAGE_KEY, next ? '1' : '0');
|
||||
} catch {
|
||||
// Storage unavailable (private browsing, etc.) -- in-memory state still works.
|
||||
}
|
||||
}, []);
|
||||
const { menuState, show: showMenu, hide: hideMenu } = useContextMenu();
|
||||
const { query } = useEditor();
|
||||
|
||||
// Register keyboard shortcuts
|
||||
useKeyboardShortcuts();
|
||||
|
||||
const handleContextMenu = useCallback((e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
// Find the selected node id
|
||||
let nodeId: string | null = null;
|
||||
try {
|
||||
const selected = query.getEvent('selected').all();
|
||||
if (selected.length > 0) {
|
||||
nodeId = selected[0];
|
||||
}
|
||||
} catch {
|
||||
// No selection
|
||||
}
|
||||
showMenu(e.clientX, e.clientY, nodeId);
|
||||
}, [query, showMenu]);
|
||||
|
||||
return (
|
||||
// Mobile-A2: shared sheet/modal-open state (see MobileChromeContext) --
|
||||
// provided around the whole shell so TopBar's Templates/Head Code modal
|
||||
// state and MobilePanelBar's sheet state live in one place. Desktop
|
||||
// doesn't render MobilePanelBar and TopBar's desktop branch behaves
|
||||
// identically to before (same booleans, just sourced from context).
|
||||
<MobileChromeProvider>
|
||||
<div className="editor-app">
|
||||
<TopBar
|
||||
device={device}
|
||||
onDeviceChange={setDevice}
|
||||
showGuides={showGuides}
|
||||
onToggleGuides={() => setShowGuides(!showGuides)}
|
||||
/>
|
||||
<div className="editor-container">
|
||||
{!isMobile && <LeftPanel />}
|
||||
<div onContextMenu={handleContextMenu} style={{ flex: 1, display: 'flex', minWidth: 0 }}>
|
||||
<Canvas device={device} showGuides={showGuides} />
|
||||
</div>
|
||||
{!isMobile && <RightPanel />}
|
||||
</div>
|
||||
{isMobile && <MobilePanelBar />}
|
||||
{isMobile && <MobileSelectionToolbar />}
|
||||
<ContextMenu
|
||||
visible={menuState.visible}
|
||||
x={menuState.x}
|
||||
y={menuState.y}
|
||||
nodeId={menuState.nodeId}
|
||||
onClose={hideMenu}
|
||||
/>
|
||||
</div>
|
||||
</MobileChromeProvider>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,67 @@
|
||||
import { describe, test, expect, vi, beforeEach } from 'vitest';
|
||||
import React from 'react';
|
||||
import { createRoot, Root } from 'react-dom/client';
|
||||
import { act } from 'react-dom/test-utils';
|
||||
|
||||
/* Same DOM-harness pattern as Footer.editguard.test.tsx: mock @craftjs/core's
|
||||
useEditor so we can drive editor state without a real <Editor> tree. */
|
||||
let mockNodes: Record<string, { data: { nodes: string[] } }> = {};
|
||||
let mockDraggedSize = 0;
|
||||
|
||||
vi.mock('@craftjs/core', () => ({
|
||||
useEditor: (collect: (state: any) => any) =>
|
||||
collect({
|
||||
nodes: mockNodes,
|
||||
events: { dragged: { size: mockDraggedSize } },
|
||||
}),
|
||||
}));
|
||||
|
||||
import { EmptyCanvasHint } from './Canvas';
|
||||
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
|
||||
function render(ui: React.ReactElement) {
|
||||
container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
act(() => {
|
||||
root = createRoot(container);
|
||||
root.render(ui);
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
mockNodes = {};
|
||||
mockDraggedSize = 0;
|
||||
});
|
||||
|
||||
describe('EmptyCanvasHint', () => {
|
||||
test('renders nothing before ROOT has mounted (no root node yet)', () => {
|
||||
render(<EmptyCanvasHint />);
|
||||
expect(container.querySelector('.empty-canvas-hint')).toBeNull();
|
||||
container.remove();
|
||||
});
|
||||
|
||||
test('renders the hint once ROOT exists with zero children', () => {
|
||||
mockNodes = { ROOT: { data: { nodes: [] } } };
|
||||
render(<EmptyCanvasHint />);
|
||||
expect(container.querySelector('.empty-canvas-hint')).not.toBeNull();
|
||||
expect(container.textContent).toContain('Drag blocks from the left panel');
|
||||
container.remove();
|
||||
});
|
||||
|
||||
test('hides once the page has content', () => {
|
||||
mockNodes = { ROOT: { data: { nodes: ['node-1'] } } };
|
||||
render(<EmptyCanvasHint />);
|
||||
expect(container.querySelector('.empty-canvas-hint')).toBeNull();
|
||||
container.remove();
|
||||
});
|
||||
|
||||
test('hides while a drag is in progress, even on an empty root', () => {
|
||||
mockNodes = { ROOT: { data: { nodes: [] } } };
|
||||
mockDraggedSize = 1;
|
||||
render(<EmptyCanvasHint />);
|
||||
expect(container.querySelector('.empty-canvas-hint')).toBeNull();
|
||||
container.remove();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,169 @@
|
||||
import { describe, test, expect, vi, beforeEach } from 'vitest';
|
||||
import React from 'react';
|
||||
import { createRoot, Root } from 'react-dom/client';
|
||||
import { act } from 'react-dom/test-utils';
|
||||
|
||||
/* Same DOM-harness pattern as Footer.editguard.test.tsx: mock @craftjs/core
|
||||
so RenderNode (the <Editor onRender> override) can be driven without a
|
||||
real Editor tree. document.body doubles as the portal target, same as
|
||||
the component itself uses. */
|
||||
let mockNode: {
|
||||
id: string;
|
||||
selected: boolean;
|
||||
hovered: boolean;
|
||||
dom: HTMLElement | null;
|
||||
displayName: string;
|
||||
parent: string | null;
|
||||
isCanvas: boolean;
|
||||
};
|
||||
const selectNodeSpy = vi.fn();
|
||||
|
||||
vi.mock('@craftjs/core', () => ({
|
||||
useEditor: () => ({ actions: { selectNode: selectNodeSpy } }),
|
||||
useNode: (collect?: (node: any) => any) => {
|
||||
const node = {
|
||||
events: { selected: mockNode.selected, hovered: mockNode.hovered },
|
||||
dom: mockNode.dom,
|
||||
data: {
|
||||
custom: {},
|
||||
displayName: mockNode.displayName,
|
||||
parent: mockNode.parent,
|
||||
isCanvas: mockNode.isCanvas,
|
||||
},
|
||||
};
|
||||
return { id: mockNode.id, ...(collect ? collect(node) : {}) };
|
||||
},
|
||||
}));
|
||||
|
||||
import { RenderNode } from './RenderNode';
|
||||
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
let nodeDom: HTMLElement;
|
||||
|
||||
function render(ui: React.ReactElement) {
|
||||
container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
act(() => {
|
||||
root = createRoot(container);
|
||||
root.render(ui);
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
nodeDom = document.createElement('div');
|
||||
document.body.appendChild(nodeDom);
|
||||
mockNode = {
|
||||
id: 'node-1',
|
||||
selected: false,
|
||||
hovered: false,
|
||||
dom: nodeDom,
|
||||
displayName: 'Heading',
|
||||
parent: 'ROOT',
|
||||
isCanvas: false,
|
||||
};
|
||||
selectNodeSpy.mockClear();
|
||||
});
|
||||
|
||||
const rendered = <span data-testid="inner">hello</span>;
|
||||
|
||||
describe('RenderNode (Editor onRender override)', () => {
|
||||
test('passes render through untouched when not selected', () => {
|
||||
render(<RenderNode render={rendered} />);
|
||||
expect(container.querySelector('[data-testid="inner"]')).not.toBeNull();
|
||||
expect(document.querySelector('.component-indicator')).toBeNull();
|
||||
container.remove();
|
||||
nodeDom.remove();
|
||||
});
|
||||
|
||||
test('shows the badge with the displayName when selected', () => {
|
||||
mockNode.selected = true;
|
||||
render(<RenderNode render={rendered} />);
|
||||
const badge = document.querySelector('.component-indicator');
|
||||
expect(badge).not.toBeNull();
|
||||
expect(badge?.textContent).toContain('Heading');
|
||||
container.remove();
|
||||
nodeDom.remove();
|
||||
document.querySelector('.component-indicator')?.remove();
|
||||
});
|
||||
|
||||
test('never shows a badge for ROOT even if "selected"', () => {
|
||||
mockNode.selected = true;
|
||||
mockNode.id = 'ROOT';
|
||||
render(<RenderNode render={rendered} />);
|
||||
expect(document.querySelector('.component-indicator')).toBeNull();
|
||||
container.remove();
|
||||
nodeDom.remove();
|
||||
});
|
||||
|
||||
test('chevron click selects the parent node', () => {
|
||||
mockNode.selected = true;
|
||||
mockNode.parent = 'parent-42';
|
||||
render(<RenderNode render={rendered} />);
|
||||
const chevron = document.querySelector('.component-indicator-parent-btn') as HTMLElement;
|
||||
expect(chevron).not.toBeNull();
|
||||
act(() => {
|
||||
chevron.dispatchEvent(new MouseEvent('mousedown', { bubbles: true }));
|
||||
});
|
||||
expect(selectNodeSpy).toHaveBeenCalledWith('parent-42');
|
||||
container.remove();
|
||||
nodeDom.remove();
|
||||
document.querySelector('.component-indicator')?.remove();
|
||||
});
|
||||
|
||||
test('no chevron when there is no parent', () => {
|
||||
mockNode.selected = true;
|
||||
mockNode.parent = null;
|
||||
render(<RenderNode render={rendered} />);
|
||||
expect(document.querySelector('.component-indicator-parent-btn')).toBeNull();
|
||||
container.remove();
|
||||
nodeDom.remove();
|
||||
document.querySelector('.component-indicator')?.remove();
|
||||
});
|
||||
|
||||
test('tags a droppable (isCanvas) node dom with data-craft-node', () => {
|
||||
mockNode.isCanvas = true;
|
||||
render(<RenderNode render={rendered} />);
|
||||
expect(nodeDom.hasAttribute('data-craft-node')).toBe(true);
|
||||
container.remove();
|
||||
nodeDom.remove();
|
||||
});
|
||||
|
||||
test('does not tag a non-canvas (leaf) node dom with data-craft-node', () => {
|
||||
mockNode.isCanvas = false;
|
||||
render(<RenderNode render={rendered} />);
|
||||
expect(nodeDom.hasAttribute('data-craft-node')).toBe(false);
|
||||
container.remove();
|
||||
nodeDom.remove();
|
||||
});
|
||||
|
||||
test('never tags ROOT with data-craft-node even though ROOT is a canvas', () => {
|
||||
mockNode.id = 'ROOT';
|
||||
mockNode.isCanvas = true;
|
||||
render(<RenderNode render={rendered} />);
|
||||
expect(nodeDom.hasAttribute('data-craft-node')).toBe(false);
|
||||
container.remove();
|
||||
nodeDom.remove();
|
||||
});
|
||||
|
||||
test('tags the dom with data-craft-hovered when the Craft hovered event is set (Layers panel hover sync)', () => {
|
||||
mockNode.hovered = true;
|
||||
render(<RenderNode render={rendered} />);
|
||||
expect(nodeDom.hasAttribute('data-craft-hovered')).toBe(true);
|
||||
container.remove();
|
||||
nodeDom.remove();
|
||||
});
|
||||
|
||||
test('removes data-craft-hovered once the hovered event clears', () => {
|
||||
mockNode.hovered = true;
|
||||
render(<RenderNode render={rendered} />);
|
||||
expect(nodeDom.hasAttribute('data-craft-hovered')).toBe(true);
|
||||
mockNode.hovered = false;
|
||||
act(() => {
|
||||
root.render(<RenderNode render={rendered} />);
|
||||
});
|
||||
expect(nodeDom.hasAttribute('data-craft-hovered')).toBe(false);
|
||||
container.remove();
|
||||
nodeDom.remove();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,116 @@
|
||||
import React, { useCallback, useEffect, useRef } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { useEditor, useNode } from '@craftjs/core';
|
||||
|
||||
interface RenderNodeProps {
|
||||
render: React.ReactElement;
|
||||
}
|
||||
|
||||
/**
|
||||
* Craft.js `<Editor onRender>` override -- wraps every node's render output.
|
||||
* For the currently-selected node it portals a floating badge (component
|
||||
* displayName + a "select parent" chevron) positioned over the node's real
|
||||
* DOM element. Non-selected nodes (the overwhelming majority) and ROOT pass
|
||||
* straight through as a Fragment, so this never touches layout, never
|
||||
* appears in `toHtml` export (that walks the Craft node tree, not this
|
||||
* portal), and doesn't wrap every node in extra DOM.
|
||||
*
|
||||
* It also imperatively tags each node's real DOM element with two
|
||||
* editor-only data attributes (never part of `toHtml` export, which walks
|
||||
* the Craft node tree, not the live DOM):
|
||||
* - `data-craft-node`: set on actual Craft.js droppable containers
|
||||
* (`node.data.isCanvas`, excluding ROOT). `editor.css`'s dashed "guide"
|
||||
* outlines target this attribute instead of blanket tag selectors
|
||||
* (div/section/header/...), so a component's own internal wrapper markup
|
||||
* no longer picks up a guide outline it isn't a real drop target for.
|
||||
* - `data-craft-hovered`: mirrors `node.events.hovered` (Craft's hover
|
||||
* event set). Craft's own `connectors.connect()` (called by every
|
||||
* component) wires a native mouseover/mouseleave listener to this event
|
||||
* internally, so a plain mouse hover over any connected node sets it --
|
||||
* this attribute is a real-mouse-hover canvas highlight. (The Layers
|
||||
* panel's row-hover -> canvas-highlight sync, item 12, uses a sibling
|
||||
* `data-layer-hovered` attribute written directly by LayersPanel.tsx
|
||||
* instead of this event, since the action that would drive it here
|
||||
* -- `actions.setNodeEvent` -- is stripped from the public `useEditor()`
|
||||
* API at runtime.) editor.css matches both attributes for the same
|
||||
* outline and suppresses both under `.guides-off`.
|
||||
*/
|
||||
export const RenderNode: React.FC<RenderNodeProps> = ({ render }) => {
|
||||
const { actions } = useEditor();
|
||||
const { id, isSelected, dom, name, parent, isCanvas, isHovered } = useNode((node) => ({
|
||||
isSelected: node.events.selected,
|
||||
isHovered: node.events.hovered,
|
||||
dom: node.dom,
|
||||
name: (node.data.props?.aiName as string) || node.data.displayName,
|
||||
parent: node.data.parent,
|
||||
isCanvas: node.data.isCanvas,
|
||||
}));
|
||||
|
||||
const badgeRef = useRef<HTMLDivElement>(null);
|
||||
const active = isSelected && id !== 'ROOT' && !!dom;
|
||||
|
||||
const updatePosition = useCallback(() => {
|
||||
if (!dom || !badgeRef.current) return;
|
||||
const rect = dom.getBoundingClientRect();
|
||||
const badgeHeight = 22;
|
||||
badgeRef.current.style.left = `${Math.max(rect.left, 0)}px`;
|
||||
badgeRef.current.style.top = `${Math.max(rect.top - badgeHeight, 0)}px`;
|
||||
}, [dom]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!active) return;
|
||||
updatePosition();
|
||||
window.addEventListener('resize', updatePosition);
|
||||
document.addEventListener('scroll', updatePosition, true);
|
||||
return () => {
|
||||
window.removeEventListener('resize', updatePosition);
|
||||
document.removeEventListener('scroll', updatePosition, true);
|
||||
};
|
||||
}, [active, updatePosition]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!dom) return;
|
||||
if (isCanvas && id !== 'ROOT') {
|
||||
dom.setAttribute('data-craft-node', '');
|
||||
} else {
|
||||
dom.removeAttribute('data-craft-node');
|
||||
}
|
||||
}, [dom, isCanvas, id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!dom) return;
|
||||
if (isHovered) {
|
||||
dom.setAttribute('data-craft-hovered', '');
|
||||
} else {
|
||||
dom.removeAttribute('data-craft-hovered');
|
||||
}
|
||||
}, [dom, isHovered]);
|
||||
|
||||
if (!active) return <>{render}</>;
|
||||
|
||||
return (
|
||||
<>
|
||||
{render}
|
||||
{createPortal(
|
||||
<div ref={badgeRef} className="component-indicator" style={{ position: 'fixed' }}>
|
||||
<span>{name}</span>
|
||||
{parent && (
|
||||
<button
|
||||
type="button"
|
||||
className="component-indicator-parent-btn"
|
||||
title="Select parent"
|
||||
aria-label={`Select parent of ${name}`}
|
||||
onMouseDown={(e) => {
|
||||
e.stopPropagation();
|
||||
actions.selectNode(parent);
|
||||
}}
|
||||
>
|
||||
<i className="fa fa-chevron-up" aria-hidden />
|
||||
</button>
|
||||
)}
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,29 @@
|
||||
import { describe, test, expect, afterEach } from 'vitest';
|
||||
import { getClipboardNodeId, setClipboardNodeId } from './clipboard';
|
||||
|
||||
describe('clipboard', () => {
|
||||
afterEach(() => {
|
||||
setClipboardNodeId(null);
|
||||
});
|
||||
|
||||
test('starts empty', () => {
|
||||
expect(getClipboardNodeId()).toBeNull();
|
||||
});
|
||||
|
||||
test('set then get returns the stored node id', () => {
|
||||
setClipboardNodeId('node-123');
|
||||
expect(getClipboardNodeId()).toBe('node-123');
|
||||
});
|
||||
|
||||
test('is a shared module-level store -- overwriting replaces the previous value', () => {
|
||||
setClipboardNodeId('first');
|
||||
setClipboardNodeId('second');
|
||||
expect(getClipboardNodeId()).toBe('second');
|
||||
});
|
||||
|
||||
test('can be cleared back to null', () => {
|
||||
setClipboardNodeId('node-123');
|
||||
setClipboardNodeId(null);
|
||||
expect(getClipboardNodeId()).toBeNull();
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user