diff --git a/craft/CLAUDE.md b/craft/CLAUDE.md
index b918c25..555d58e 100644
--- a/craft/CLAUDE.md
+++ b/craft/CLAUDE.md
@@ -41,14 +41,12 @@ craft/
│ │ └── Canvas.tsx # Craft.js with device-width switching
│ │
│ ├── components/
-│ │ ├── resolver.ts # Component map for Craft.js serialization (20 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
-│ │ │ ├── HeaderZone.tsx # Page-level header zone wrapper
-│ │ │ └── FooterZone.tsx # Page-level footer zone wrapper
+│ │ │ └── BackgroundSection.tsx # Section with background image/gradient overlay
│ │ ├── basic/
│ │ │ ├── Heading.tsx # Inline-editable heading (h1-h6)
│ │ │ ├── TextBlock.tsx # Inline-editable paragraph
@@ -73,7 +71,8 @@ craft/
│ ├── panels/
│ │ ├── topbar/
│ │ │ ├── TopBar.tsx # Back button, domain badge, device switcher, undo/redo, save, templates
-│ │ │ └── TemplateModal.tsx # Template browser with categories and one-click loading
+│ │ │ ├── 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
@@ -81,9 +80,10 @@ craft/
│ │ │ ├── LayersPanel.tsx # Component hierarchy tree view
│ │ │ └── AssetsPanel.tsx # Asset browser with upload, drag-drop, thumbnails
│ │ ├── right/
-│ │ │ ├── RightPanel.tsx # Tabs: Styles | Settings | Head
-│ │ │ ├── GuidedStyles.tsx # Context-aware style panel (shows selected type)
-│ │ │ └── SiteDesignPanel.tsx # Site-wide design tokens editor (Basic/Advanced tabs)
+│ │ │ ├── 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.)
│ │
@@ -98,7 +98,8 @@ craft/
│ │ └── definitions.ts # 16 template definitions across 4 categories
│ │
│ ├── ui/
-│ │ └── SettingsTabs.tsx # Reusable General/Style/Advanced tabs for component settings
+│ │ ├── 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
@@ -165,7 +166,7 @@ The PHP wrapper (`/docker/whp/web/site-builder/index.php`) injects `WHP_CONFIG`
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`. All rendering, settings UI, and HTML export are co-located in one file.
+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.
@@ -188,28 +189,21 @@ export const MyComponent: UserComponent = ({ text, style }) =>
return { if (r) connect(drag(r)); }} style={style}>{text}
;
};
-// 3. Settings panel (rendered in right panel when selected)
-const MyComponentSettings: React.FC = () => {
- const { actions: { setProp }, props } = useNode((node) => ({
- props: node.data.props as MyComponentProps,
- }));
- return /* preset buttons, inputs, etc. */
;
-};
-
-// 4. Craft config (displayName, default props, rules, related settings)
+// 3. Craft config (displayName, default props, rules)
MyComponent.craft = {
displayName: 'My Component',
props: { text: 'Default text', style: {} },
rules: { canDrag: () => true, canMoveIn: () => false, canMoveOut: () => true },
- related: { settings: MyComponentSettings },
};
-// 5. HTML export (static method for serializing to HTML string)
+// 4. HTML export (static method for serializing to HTML string)
(MyComponent as any).toHtml = (props: MyComponentProps, childrenHtml: string) => {
return { html: `${childrenHtml}
` };
};
```
+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 `` so Craft.js can serialize/deserialize the node tree.
@@ -270,7 +264,7 @@ The editor auto-saves every 30 seconds when running inside WHP. The save status
- `whpConfig` - The full config object (or null in standalone mode)
- `isWHP` - Boolean shorthand for whether we're running inside WHP
-## All Components (22)
+## All Components (39)
| # | Component | Type | File | Features |
|---|-----------|------|------|----------|
@@ -278,24 +272,41 @@ The editor auto-saves every 30 seconds when running inside WHP. The save status
| 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 | HeaderZone | Layout | `layout/HeaderZone.tsx` | Page-level header wrapper zone, used by PageContext |
-| 6 | FooterZone | Layout | `layout/FooterZone.tsx` | Page-level footer wrapper zone, used by PageContext |
-| 7 | Heading | Basic | `basic/Heading.tsx` | Inline-editable, h1-h6 level, color, font family/size/weight, text align |
-| 8 | TextBlock | Basic | `basic/TextBlock.tsx` | Inline-editable paragraph, color, font family/size/weight, text align, line height |
-| 9 | ButtonLink | Basic | `basic/ButtonLink.tsx` | Link text/URL/target, 8 color presets (auto text contrast), radius, padding, font size |
+| 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 | ImageBlock | Media | `media/ImageBlock.tsx` | SVG placeholder, URL input, upload, browse assets, alt text, width/height, object-fit, radius |
-| 15 | VideoBlock | Media | `media/VideoBlock.tsx` | YouTube, Vimeo, direct files (.mp4/.webm/.ogg), background mode, autoplay, loop |
-| 16 | HeroSimple | Section | `sections/HeroSimple.tsx` | Pre-built hero with heading, subtext, CTA button, gradient/color background |
-| 17 | FeaturesGrid | Section | `sections/FeaturesGrid.tsx` | 3-column feature cards with icons, titles, descriptions |
-| 18 | CTASection | Section | `sections/CTASection.tsx` | Call-to-action banner with heading, text, button |
-| 19 | FormContainer | Form | `forms/FormContainer.tsx` | Form wrapper with action URL and method |
-| 20 | InputField | Form | `forms/InputField.tsx` | Text input with label, placeholder, type (text/email/tel/password/number) |
-| 21 | TextareaField | Form | `forms/TextareaField.tsx` | Textarea with label and placeholder |
-| 22 | FormButton | Form | `forms/FormButton.tsx` | Submit button with color and style controls |
+| 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
@@ -351,7 +362,7 @@ Templates are loaded via the Template Modal (opened from TopBar). Loading a temp
## Multi-Page System
Pages are managed through `PageContext`:
-- Each page has: `id`, `name`, `slug`, `craftState`, `headCode`
+- 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
@@ -392,21 +403,22 @@ The Assets panel (`AssetsPanel.tsx`) provides:
- Delete asset
- Integration with WHP API for server-side storage
-Image and Video components also have inline asset selection (browse button in settings).
+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//.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. Implement the `toHtml` static for HTML export
-5. Build and test: `npm run dev`, drag the block onto the canvas, verify settings panel, verify HTML export
+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
-- [ ] Settings panel using `useNode()` with `setProp()`
-- [ ] `.craft` config with `displayName`, default `props`, `rules`, `related.settings`
+- [ ] `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`
@@ -421,7 +433,7 @@ The editor uses a dark theme defined via CSS custom properties in `src/styles/ed
- **Border:** `#2d2d3a`
- **Font:** Inter
-All editor chrome (panels, topbar, settings) is styled via `editor.css`. User content on the canvas uses inline styles exclusively.
+All editor chrome (panels, topbar, style panels) is styled via `editor.css`. User content on the canvas uses inline styles exclusively.
## Presets
@@ -446,7 +458,7 @@ Every component has a static `toHtml(props, childrenHtml)` method. The `html-exp
## Testing Approach
-- **Manual testing:** Run `npm run dev`, drag components, edit props, verify settings panels
+- **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
@@ -455,9 +467,9 @@ Every component has a static `toHtml(props, childrenHtml)` method. The `html-exp
## Development Notes
- Path alias `@/` maps to `./src/` (configured in both tsconfig.json and vite.config.ts)
-- `GuidedStyles` shows selected component type and delegates to per-component settings panels
+- `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
-- The SettingsTabs UI component provides a reusable General/Style/Advanced tab layout for component settings
+- `AssetPicker` (`src/ui/AssetPicker.tsx`) is the shared upload/browse/URL control reused across StylePanels for every image and video field
diff --git a/craft/src/components/basic/ButtonLink.toHtml.test.ts b/craft/src/components/basic/ButtonLink.toHtml.test.ts
new file mode 100644
index 0000000..82fe9fe
--- /dev/null
+++ b/craft/src/components/basic/ButtonLink.toHtml.test.ts
@@ -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 = '">';
+ const { html } = toHtml({ href: malicious, text: 'Click' }, '');
+ expect(html).not.toContain('');
+ });
+
+ 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: '
' }, '');
+ expect(html).not.toContain('
{
+ 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');
+ });
+});
diff --git a/craft/src/components/basic/ButtonLink.tsx b/craft/src/components/basic/ButtonLink.tsx
index 6cbfdcb..2b46a4f 100644
--- a/craft/src/components/basic/ButtonLink.tsx
+++ b/craft/src/components/basic/ButtonLink.tsx
@@ -1,6 +1,7 @@
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;
@@ -44,150 +45,6 @@ export const ButtonLink: UserComponent = ({
);
};
-/* ---------- Settings panel ---------- */
-
-const ButtonLinkSettings: React.FC = () => {
- const { actions: { setProp }, props } = useNode((node) => ({
- props: node.data.props as ButtonLinkProps,
- }));
-
- const colorPresets = [
- { bg: '#3b82f6', color: '#ffffff', label: 'Blue' },
- { bg: '#10b981', color: '#ffffff', label: 'Green' },
- { bg: '#ef4444', color: '#ffffff', label: 'Red' },
- { bg: '#f59e0b', color: '#18181b', label: 'Amber' },
- { bg: '#8b5cf6', color: '#ffffff', label: 'Purple' },
- { bg: '#18181b', color: '#ffffff', label: 'Dark' },
- { bg: '#ffffff', color: '#18181b', label: 'White' },
- { bg: 'transparent', color: '#3b82f6', label: 'Ghost' },
- ];
- const radiusPresets = ['0px', '4px', '8px', '12px', '9999px'];
- const paddingPresets = ['8px 16px', '10px 20px', '12px 24px', '14px 32px', '16px 40px'];
-
- return (
-
-
-
- setProp((p: ButtonLinkProps) => { p.text = e.target.value; })}
- style={{ width: '100%', padding: '4px 8px', background: '#27272a', color: '#e4e4e7', border: '1px solid #3f3f46', borderRadius: 4, fontSize: 12 }}
- />
-
-
-
-
- setProp((p: ButtonLinkProps) => { p.href = e.target.value; })}
- placeholder="https://..."
- style={{ width: '100%', padding: '4px 8px', background: '#27272a', color: '#e4e4e7', border: '1px solid #3f3f46', borderRadius: 4, fontSize: 12 }}
- />
-
-
-
-
-
- {(['_self', '_blank'] as const).map((t) => (
-
- ))}
-
-
-
-
-
-
- {colorPresets.map((preset) => (
-
-
-
-
-
-
- {radiusPresets.map((r) => (
-
- ))}
-
-
-
-
-
-
- {paddingPresets.map((p) => (
-
- ))}
-
-
-
-
-
- setProp((p: ButtonLinkProps) => { p.style = { ...p.style, fontSize: e.target.value }; })}
- style={{ width: '100%', padding: '4px 8px', background: '#27272a', color: '#e4e4e7', border: '1px solid #3f3f46', borderRadius: 4, fontSize: 11 }}
- />
-
-
- );
-};
-
/* ---------- Craft config ---------- */
ButtonLink.craft = {
@@ -211,9 +68,6 @@ ButtonLink.craft = {
canMoveIn: () => false,
canMoveOut: () => true,
},
- related: {
- settings: ButtonLinkSettings,
- },
};
/* ---------- HTML export ---------- */
@@ -224,9 +78,9 @@ ButtonLink.craft = {
textDecoration: 'none',
...props.style,
});
- const escapedText = (props.text || '').replace(//g, '>');
+ const escapedText = escapeHtml(props.text || '');
const targetAttr = props.target === '_blank' ? ' target="_blank" rel="noopener noreferrer"' : '';
return {
- html: `${escapedText}`,
+ html: `${escapedText}`,
};
};
diff --git a/craft/src/components/basic/Divider.toHtml.test.ts b/craft/src/components/basic/Divider.toHtml.test.ts
new file mode 100644
index 0000000..db186b5
--- /dev/null
+++ b/craft/src/components/basic/Divider.toHtml.test.ts
@@ -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 ';
+ const { html } = toHtml({ thickness: '1px', color: malicious as any }, '');
+ expect(html).not.toContain('');
+ });
+
+ 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="/);
+ });
+});
diff --git a/craft/src/components/basic/Divider.tsx b/craft/src/components/basic/Divider.tsx
index aa349f5..707c07d 100644
--- a/craft/src/components/basic/Divider.tsx
+++ b/craft/src/components/basic/Divider.tsx
@@ -34,59 +34,6 @@ export const Divider: UserComponent = ({
);
};
-/* ---------- Settings panel ---------- */
-
-const DividerSettings: React.FC = () => {
- const { actions: { setProp }, props } = useNode((node) => ({
- props: node.data.props as DividerProps,
- }));
-
- const colorPresets = ['#e4e4e7', '#d4d4d8', '#a1a1aa', '#3f3f46', '#18181b', '#3b82f6', '#ef4444', '#10b981'];
- const thicknessPresets = ['1px', '2px', '3px', '4px', '6px'];
-
- return (
-
-
-
-
- {colorPresets.map((c) => (
-
-
-
-
-
-
- {thicknessPresets.map((t) => (
-
- ))}
-
-
-
- );
-};
-
/* ---------- Craft config ---------- */
Divider.craft = {
@@ -101,9 +48,6 @@ Divider.craft = {
canMoveIn: () => false,
canMoveOut: () => true,
},
- related: {
- settings: DividerSettings,
- },
};
/* ---------- HTML export ---------- */
diff --git a/craft/src/components/basic/Footer.editguard.test.tsx b/craft/src/components/basic/Footer.editguard.test.tsx
new file mode 100644
index 0000000..95a03ff
--- /dev/null
+++ b/craft/src/components/basic/Footer.editguard.test.tsx
@@ -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 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();
+
+ 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();
+
+ 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();
+
+ 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();
+
+ mockSelected = false;
+ rerender();
+
+ expect(setPropSpy).not.toHaveBeenCalled();
+
+ container.remove();
+ });
+});
diff --git a/craft/src/components/basic/Footer.toHtml.test.ts b/craft/src/components/basic/Footer.toHtml.test.ts
new file mode 100644
index 0000000..c25d19a
--- /dev/null
+++ b/craft/src/components/basic/Footer.toHtml.test.ts
@@ -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: '
' }, '');
+ expect(html).not.toContain('
{
+ 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.');
+ });
+});
diff --git a/craft/src/components/basic/Footer.tsx b/craft/src/components/basic/Footer.tsx
index 4b75040..f8b87d0 100644
--- a/craft/src/components/basic/Footer.tsx
+++ b/craft/src/components/basic/Footer.tsx
@@ -1,6 +1,7 @@
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;
@@ -20,16 +21,33 @@ export const Footer: UserComponent = ({
}));
const elRef = useRef(null);
+ const editedTextRef = useRef(null);
- const handleBlur = useCallback(() => {
+ 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 (elRef.current && !selected) {
+ 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]);
@@ -43,6 +61,12 @@ export const Footer: UserComponent = ({
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',
@@ -56,67 +80,6 @@ export const Footer: UserComponent = ({
);
};
-/* ---------- Settings panel ---------- */
-
-const FooterSettings: React.FC = () => {
- const { actions: { setProp }, props } = useNode((node) => ({
- props: node.data.props as FooterProps,
- }));
-
- const bgPresets = ['#ffffff', '#f8fafc', '#18181b', '#0f172a', '#1e293b'];
- const colorPresets = ['#18181b', '#3f3f46', '#71717a', '#a1a1aa', '#e4e4e7', '#ffffff'];
-
- return (
-
-
-
- setProp((p: FooterProps) => { p.text = e.target.value; })}
- style={{ width: '100%', padding: '4px 8px', background: '#27272a', color: '#e4e4e7', border: '1px solid #3f3f46', borderRadius: 4, fontSize: 12 }}
- />
-
-
-
-
-
- {bgPresets.map((c) => (
-
-
-
-
-
-
- {colorPresets.map((c) => (
-
-
-
- );
-};
-
/* ---------- Craft config ---------- */
Footer.craft = {
@@ -135,9 +98,6 @@ Footer.craft = {
canMoveIn: () => false,
canMoveOut: () => true,
},
- related: {
- settings: FooterSettings,
- },
};
/* ---------- HTML export ---------- */
@@ -148,6 +108,6 @@ Footer.craft = {
textAlign: 'center',
...props.style,
});
- const escapedText = (props.text || '').replace(//g, '>');
+ const escapedText = escapeHtml(props.text || '');
return { html: `` };
};
diff --git a/craft/src/components/basic/Heading.toHtml.test.ts b/craft/src/components/basic/Heading.toHtml.test.ts
new file mode 100644
index 0000000..112481f
--- /dev/null
+++ b/craft/src/components/basic/Heading.toHtml.test.ts
@@ -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
, no broken-out tag', () => {
+ const { html } = toHtml({ text: 'x', level: '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('')).toBe(true);
+ });
+
+ test('a non-heading string level clamps to h2', () => {
+ const { html } = toHtml({ text: 'x', level: 'script' as any }, '');
+ expect(html.startsWith(' {
+ const { html } = toHtml({ text: 'x', level: 'h4' }, '');
+ expect(html).toContain('');
+ });
+
+ 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: '
', level: 'h2' }, '');
+ expect(html).not.toContain('
{
+ 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('Hello world
');
+ });
+});
diff --git a/craft/src/components/basic/Heading.tsx b/craft/src/components/basic/Heading.tsx
index 3131596..4f0accd 100644
--- a/craft/src/components/basic/Heading.tsx
+++ b/craft/src/components/basic/Heading.tsx
@@ -1,12 +1,20 @@
import React, { CSSProperties, useCallback, useRef, useEffect } from 'react';
import { useNode, UserComponent } from '@craftjs/core';
import { cssPropsToString } from '../../utils/style-helpers';
-import { SettingsTabs } from '../../ui/SettingsTabs';
-import { TypographyControl } from '../../ui/TypographyControl';
-import { AdvancedTab } from '../../ui/AdvancedTab';
+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>
+ (ALLOWED_HEADING_LEVELS as readonly unknown[]).includes(level) ? (level as HeadingLevel) : 'h2';
+
interface HeadingProps {
text?: string;
level?: HeadingLevel;
@@ -33,6 +41,7 @@ export const Heading: UserComponent = ({
selected: node.events.selected,
}));
+ const safeLevel = sanitizeHeadingLevel(level);
const elRef = useRef(null);
const editedTextRef = useRef(null);
@@ -62,7 +71,7 @@ export const Heading: UserComponent = ({
}
}, [text, selected]);
- return React.createElement(level, {
+ return React.createElement(safeLevel, {
ref: (ref: HTMLElement | null): void => {
elRef.current = ref;
if (ref) connect(drag(ref));
@@ -80,76 +89,6 @@ export const Heading: UserComponent = ({
});
};
-/* ---------- Settings panel ---------- */
-
-const HeadingSettings: React.FC = () => {
- const { actions: { setProp }, props } = useNode((node) => ({
- props: node.data.props as HeadingProps,
- }));
-
- const levels: HeadingLevel[] = ['h1', 'h2', 'h3', 'h4', 'h5', 'h6'];
-
- return (
-
-
-
-
- {levels.map((l) => (
-
- ))}
-
-
-
-
- setProp((p: HeadingProps) => { p.text = e.target.value; })}
- style={{ width: '100%', padding: '6px 8px', background: '#27272a', color: '#e4e4e7', border: '1px solid #3f3f46', borderRadius: 4, fontSize: 13 }}
- />
-
-
- }
- style={
- setProp((p: HeadingProps) => { p.style = { ...p.style, ...updates }; })}
- />
- }
- advanced={
- setProp((p: HeadingProps) => { p.style = { ...p.style, ...updates }; })}
- cssId={props.cssId || ''}
- onCssIdChange={(id) => setProp((p: HeadingProps) => { p.cssId = id; })}
- cssClass={props.cssClass || ''}
- onCssClassChange={(cls) => setProp((p: HeadingProps) => { p.cssClass = cls; })}
- hideOnDesktop={props.hideOnDesktop}
- onHideOnDesktopChange={(v) => setProp((p: HeadingProps) => { p.hideOnDesktop = v; })}
- hideOnTablet={props.hideOnTablet}
- onHideOnTabletChange={(v) => setProp((p: HeadingProps) => { p.hideOnTablet = v; })}
- hideOnMobile={props.hideOnMobile}
- onHideOnMobileChange={(v) => setProp((p: HeadingProps) => { p.hideOnMobile = v; })}
- animation={props.animation}
- onAnimationChange={(v) => setProp((p: HeadingProps) => { p.animation = v; })}
- animationDelay={props.animationDelay}
- onAnimationDelayChange={(v) => setProp((p: HeadingProps) => { p.animationDelay = v; })}
- />
- }
- />
- );
-};
-
Heading.craft = {
displayName: 'Heading',
props: {
@@ -168,14 +107,11 @@ Heading.craft = {
canMoveIn: () => false,
canMoveOut: () => true,
},
- related: {
- settings: HeadingSettings,
- },
};
(Heading as any).toHtml = (props: HeadingProps, _childrenHtml: string) => {
- const tag = props.level || 'h2';
- const safeText = (props.text || '').replace(//g, '>');
+ const tag = sanitizeHeadingLevel(props.level);
+ const safeText = escapeHtml(props.text || '');
const styleStr = cssPropsToString(props.style);
return { html: `<${tag}${styleStr ? ` style="${styleStr}"` : ''}>${safeText}${tag}>` };
};
diff --git a/craft/src/components/basic/HtmlBlock.toHtml.test.ts b/craft/src/components/basic/HtmlBlock.toHtml.test.ts
new file mode 100644
index 0000000..35e939e
--- /dev/null
+++ b/craft/src/components/basic/HtmlBlock.toHtml.test.ts
@@ -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 hi
' }, '');
+ expect(html).not.toContain('';
+ const { html } = toHtml({ icon: malicious as any }, '');
+ expect(html).not.toContain('');
+ expect(html).not.toMatch(/class="fa star">';
+ const { html } = toHtml({ href: malicious }, '');
+ expect(html).not.toContain('');
+ });
+});
+
+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 = '">';
+ const { html } = toHtml({ type: 'image', imageSrc: 'https://example.com/logo.png', text: malicious }, '');
+ expect(html).not.toContain('');
+ });
+
+ test('a non-numeric imageWidth (attribute-breakout attempt) does not escape the style attribute', () => {
+ const malicious = '1">';
+ const { html } = toHtml({ type: 'image', imageSrc: 'https://example.com/logo.png', imageWidth: malicious }, '');
+ expect(html).not.toContain('');
+ });
+});
+
+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');
+ });
+});
diff --git a/craft/src/components/basic/Logo.tsx b/craft/src/components/basic/Logo.tsx
index 984a01a..429f11e 100644
--- a/craft/src/components/basic/Logo.tsx
+++ b/craft/src/components/basic/Logo.tsx
@@ -1,7 +1,8 @@
-import React, { CSSProperties, useCallback, useRef, useState } from 'react';
+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 } from '../../utils/escape';
/* ---------- Types ---------- */
@@ -18,31 +19,6 @@ interface LogoProps {
style?: CSSProperties;
}
-/* ---------- Image upload helper ---------- */
-
-async function uploadToWhp(file: File): Promise {
- const cfg = (window as any).WHP_CONFIG;
- if (!cfg) return URL.createObjectURL(file);
- 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; }
-}
-
-/* ---------- Helper: escape HTML ---------- */
-function esc(str: any): string {
- str = String(str ?? "");
- return str.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"');
-}
-
/* ---------- Component ---------- */
export const Logo: UserComponent = ({
@@ -97,269 +73,6 @@ export const Logo: UserComponent = ({
);
};
-/* ---------- Settings panel ---------- */
-
-const LogoSettings: React.FC = () => {
- const { actions: { setProp }, props } = useNode((node) => ({
- props: node.data.props as LogoProps,
- }));
-
- const { design } = useSiteDesign();
- const logoType = props.type || 'text';
-
- const fileInputRef = useRef(null);
- const [showBrowser, setShowBrowser] = useState(false);
- const [browserAssets, setBrowserAssets] = useState([]);
- const [browserLoading, setBrowserLoading] = useState(false);
-
- const fontFamilies = [
- { label: 'Inter', value: 'Inter, sans-serif' },
- { label: 'Roboto', value: 'Roboto, 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' },
- { label: 'Open Sans', value: 'Open Sans, sans-serif' },
- ];
-
- const handleLogoUpload = useCallback(async (file: File) => {
- const url = await uploadToWhp(file);
- if (url) setProp((p: LogoProps) => { p.imageSrc = url; });
- }, [setProp]);
-
- const handleBrowse = useCallback(async () => {
- if (showBrowser) { setShowBrowser(false); return; }
- const cfg = (window as any).WHP_CONFIG;
- if (!cfg) return;
- setBrowserLoading(true);
- try {
- const resp = await fetch(`${cfg.apiUrl}?action=list_assets&site_id=${cfg.siteId}`);
- const data = await resp.json();
- if (data.success && Array.isArray(data.assets)) {
- const images = data.assets.filter((a: any) => (a.type || '').startsWith('image'));
- setBrowserAssets(images);
- setShowBrowser(true);
- }
- } catch (e) {
- console.error('Browse failed:', e);
- } finally {
- setBrowserLoading(false);
- }
- }, [showBrowser]);
-
- /* ---- Shared styles ---- */
- const labelStyle: CSSProperties = { fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 4 };
- const inputStyle: CSSProperties = {
- width: '100%', padding: '3px 6px', background: '#27272a', color: '#e4e4e7',
- border: '1px solid #3f3f46', borderRadius: 4, fontSize: 11,
- };
- const btnSmall: CSSProperties = {
- padding: '2px 6px', fontSize: 11, background: '#27272a', color: '#a1a1aa',
- border: '1px solid #3f3f46', borderRadius: 4, cursor: 'pointer',
- };
- const btnActive: CSSProperties = {
- ...btnSmall, background: '#3b82f6', color: '#fff', borderColor: '#3b82f6',
- };
-
- return (
-
- {/* Type toggle */}
-
-
-
-
-
-
-
-
- {logoType === 'text' ? (
- <>
-
-
- setProp((p: LogoProps) => { p.text = e.target.value; })}
- style={inputStyle}
- />
-
-
-
-
-
-
-
-
- setProp((p: LogoProps) => { p.fontSize = e.target.value; })}
- placeholder="20px"
- style={inputStyle}
- />
-
-
-
-
-
-
-
-
-
- setProp((p: LogoProps) => { p.color = e.target.value; })}
- style={{ width: 28, height: 24, padding: 0, border: '1px solid #3f3f46', borderRadius: 3, cursor: 'pointer', background: 'none' }}
- />
- {props.color || 'Auto'}
-
-
-
- >
- ) : (
- <>
- {/* Image logo controls */}
- {props.imageSrc ? (
-
-

-
-
- ) : (
-
fileInputRef.current?.click()}
- onDragOver={(e) => { e.preventDefault(); e.currentTarget.style.borderColor = '#3b82f6'; }}
- onDragLeave={(e) => { e.currentTarget.style.borderColor = '#3f3f46'; }}
- onDrop={async (e) => {
- e.preventDefault();
- e.currentTarget.style.borderColor = '#3f3f46';
- const file = e.dataTransfer.files?.[0];
- if (file && file.type.startsWith('image/')) await handleLogoUpload(file);
- }}
- >
-
- Drop logo or click to upload
-
- )}
-
-
-
-
-
-
- {/* Browse grid */}
- {showBrowser && (
-
- {browserAssets.map(asset => (
-
{ setProp((p: LogoProps) => { p.imageSrc = asset.url; }); setShowBrowser(false); }}
- style={{ cursor: 'pointer', borderRadius: 4, overflow: 'hidden', border: '2px solid transparent', aspectRatio: '1' }}
- onMouseEnter={(e) => { e.currentTarget.style.borderColor = '#3b82f6'; }}
- onMouseLeave={(e) => { e.currentTarget.style.borderColor = 'transparent'; }}
- >
-

-
- ))}
- {browserAssets.length === 0 && (
-
No images uploaded yet.
- )}
-
- )}
-
-
{ const file = e.target.files?.[0]; if (file) handleLogoUpload(file); e.target.value = ''; }} />
-
- {/* URL paste input */}
-
- setProp((p: LogoProps) => { p.imageSrc = e.target.value; })}
- placeholder="Or paste image URL..."
- style={{ ...inputStyle, fontSize: 10, color: '#71717a' }}
- />
-
-
-
-
- setProp((p: LogoProps) => { p.imageWidth = e.target.value; })}
- placeholder="120px"
- style={inputStyle}
- />
-
- >
- )}
-
- {/* Link URL */}
-
-
- setProp((p: LogoProps) => { p.href = e.target.value; })}
- placeholder="/"
- style={inputStyle}
- />
-
-
- );
-};
-
/* ---------- Craft config ---------- */
Logo.craft = {
@@ -381,9 +94,6 @@ Logo.craft = {
canMoveIn: () => false,
canMoveOut: () => true,
},
- related: {
- settings: LogoSettings,
- },
};
/* ---------- HTML export ---------- */
@@ -394,7 +104,7 @@ Logo.craft = {
let innerHtml: string;
if (props.type === 'image' && props.imageSrc) {
const imgStyle = cssPropsToString({ width: props.imageWidth || '120px', height: 'auto', display: 'block' });
- innerHtml = `
`;
+ innerHtml = `
`;
} else {
const spanStyle = cssPropsToString({
fontWeight: props.fontWeight || '700',
@@ -402,7 +112,7 @@ Logo.craft = {
fontFamily: props.fontFamily || 'Inter, sans-serif',
color: props.color || '#1f2937',
});
- innerHtml = `${esc(props.text || 'MySite')}`;
+ innerHtml = `${escapeHtml(props.text || 'MySite')}`;
}
const aStyle = cssPropsToString({
@@ -414,6 +124,6 @@ Logo.craft = {
});
return {
- html: `${innerHtml}`,
+ html: `${innerHtml}`,
};
};
diff --git a/craft/src/components/basic/Menu.toHtml.test.ts b/craft/src/components/basic/Menu.toHtml.test.ts
new file mode 100644
index 0000000..ee4ac32
--- /dev/null
+++ b/craft/src/components/basic/Menu.toHtml.test.ts
@@ -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 ');
+ });
+
+ 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/);
+ });
+});
diff --git a/craft/src/components/basic/Menu.tsx b/craft/src/components/basic/Menu.tsx
index 5963187..e91b4b5 100644
--- a/craft/src/components/basic/Menu.tsx
+++ b/craft/src/components/basic/Menu.tsx
@@ -1,7 +1,7 @@
import React, { CSSProperties, useState } from 'react';
import { useNode, UserComponent } from '@craftjs/core';
import { cssPropsToString } from '../../utils/style-helpers';
-import { usePages } from '../../state/PageContext';
+import { escapeHtml, escapeAttr, safeUrl, scopeId, cssValue } from '../../utils/escape';
/* ---------- Types ---------- */
@@ -34,12 +34,6 @@ const defaultLinks: MenuLink[] = [
{ text: 'Contact', href: '#contact', isCta: true },
];
-/* ---------- Helper: escape HTML ---------- */
-function esc(str: any): string {
- str = String(str ?? "");
- return str.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"');
-}
-
/* ---------- Component ---------- */
export const Menu: UserComponent = ({
@@ -105,325 +99,6 @@ export const Menu: UserComponent = ({
);
};
-/* ---------- Settings panel ---------- */
-
-const MenuSettings: React.FC = () => {
- const { actions: { setProp }, props } = useNode((node) => ({
- props: node.data.props as MenuProps,
- }));
-
- const { pages } = usePages();
- const links = props.links || defaultLinks;
-
- /* Drag state for reordering */
- const [dragIdx, setDragIdx] = useState(null);
- const [dragOverIdx, setDragOverIdx] = useState(null);
-
- /* ---- Link management ---- */
-
- const updateLink = (index: number, field: keyof MenuLink, value: string | boolean) => {
- setProp((p: MenuProps) => {
- const updated = [...(p.links || defaultLinks)];
- updated[index] = { ...updated[index], [field]: value };
- p.links = updated;
- });
- };
-
- const addLink = (link?: Partial) => {
- setProp((p: MenuProps) => {
- p.links = [...(p.links || defaultLinks), { text: 'Link', href: '#', ...link }];
- });
- };
-
- const removeLink = (index: number) => {
- setProp((p: MenuProps) => {
- const updated = [...(p.links || defaultLinks)];
- updated.splice(index, 1);
- p.links = updated;
- });
- };
-
- const moveLink = (fromIdx: number, toIdx: number) => {
- if (fromIdx === toIdx) return;
- setProp((p: MenuProps) => {
- const updated = [...(p.links || defaultLinks)];
- const [moved] = updated.splice(fromIdx, 1);
- updated.splice(toIdx, 0, moved);
- p.links = updated;
- });
- };
-
- /* ---- Shared styles ---- */
- const labelStyle: CSSProperties = { fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 4 };
- const inputStyle: CSSProperties = {
- width: '100%', padding: '3px 6px', background: '#27272a', color: '#e4e4e7',
- border: '1px solid #3f3f46', borderRadius: 4, fontSize: 11,
- };
- const sectionStyle: CSSProperties = {
- borderBottom: '1px solid #27272a', paddingBottom: 12,
- };
- const btnSmall: CSSProperties = {
- padding: '2px 6px', fontSize: 11, background: '#27272a', color: '#a1a1aa',
- border: '1px solid #3f3f46', borderRadius: 4, cursor: 'pointer',
- };
- const btnActive: CSSProperties = {
- ...btnSmall, background: '#3b82f6', color: '#fff', borderColor: '#3b82f6',
- };
-
- const textColorPresets = ['#1f2937', '#374151', '#3f3f46', '#6b7280', '#ffffff', '#e4e4e7', '#a1a1aa', '#3b82f6'];
- const gapPresets = ['8px', '16px', '24px', '32px', '40px'];
-
- return (
-
-
- {/* ===== Style Section ===== */}
-
-
-
- {/* Link color */}
-
-
-
- {textColorPresets.map((c) => (
-
-
-
- {/* Hover color */}
-
-
-
- setProp((p: MenuProps) => { p.linkHoverColor = e.target.value; })}
- style={{ width: 28, height: 24, padding: 0, border: '1px solid #3f3f46', borderRadius: 3, cursor: 'pointer', background: 'none' }}
- />
- {props.linkHoverColor || '#3b82f6'}
-
-
-
- {/* CTA button colors */}
-
-
-
-
- BG
- setProp((p: MenuProps) => { p.ctaBgColor = e.target.value; })}
- style={{ display: 'block', width: 28, height: 20, padding: 0, border: '1px solid #3f3f46', borderRadius: 3, cursor: 'pointer', background: 'none' }}
- />
-
-
- Text
- setProp((p: MenuProps) => { p.ctaTextColor = e.target.value; })}
- style={{ display: 'block', width: 28, height: 20, padding: 0, border: '1px solid #3f3f46', borderRadius: 3, cursor: 'pointer', background: 'none' }}
- />
-
-
-
-
- {/* Font size */}
-
-
- setProp((p: MenuProps) => { p.fontSize = e.target.value; })}
- placeholder="14px"
- style={inputStyle}
- />
-
-
- {/* Alignment */}
-
-
-
- {(['left', 'center', 'right'] as const).map((a) => (
-
- ))}
-
-
-
- {/* Orientation */}
-
-
-
- {(['horizontal', 'vertical'] as const).map((o) => (
-
- ))}
-
-
-
- {/* Gap */}
-
-
-
- {gapPresets.map((g) => (
-
- ))}
-
-
-
-
- {/* ===== Links Section ===== */}
-
-
-
-
- {links.map((link, i) => (
-
setDragIdx(i)}
- onDragOver={(e) => { e.preventDefault(); setDragOverIdx(i); }}
- onDragEnd={() => {
- if (dragIdx !== null && dragOverIdx !== null) {
- moveLink(dragIdx, dragOverIdx);
- }
- setDragIdx(null);
- setDragOverIdx(null);
- }}
- style={{
- background: dragOverIdx === i && dragIdx !== null && dragIdx !== i ? '#1e293b' : '#1e1e22',
- borderRadius: 6,
- padding: 8,
- display: 'flex',
- flexDirection: 'column',
- gap: 4,
- border: dragOverIdx === i && dragIdx !== null && dragIdx !== i ? '1px solid #3b82f6' : '1px solid transparent',
- transition: 'background 0.1s, border-color 0.1s',
- }}
- >
- {/* Row 1: drag handle + text + delete */}
-
-
-
-
- updateLink(i, 'text', e.target.value)}
- placeholder="Text"
- style={{ ...inputStyle, flex: 1 }}
- />
-
-
-
- {/* Row 2: URL */}
-
updateLink(i, 'href', e.target.value)}
- placeholder="URL (e.g. /about or https://...)"
- style={inputStyle}
- />
-
- {/* Row 3: checkboxes */}
-
-
-
-
-
- ))}
-
-
- {/* Add link button */}
-
-
- {/* Add page dropdown */}
-
-
-
- );
-};
-
/* ---------- Craft config ---------- */
Menu.craft = {
@@ -445,22 +120,23 @@ Menu.craft = {
canMoveIn: () => false,
canMoveOut: () => true,
},
- related: {
- settings: MenuSettings,
- },
};
/* ---------- HTML export ---------- */
-(Menu as any).toHtml = (props: MenuProps, _childrenHtml: string) => {
- const linkCol = props.linkColor || '#3f3f46';
- const hoverCol = props.linkHoverColor || '#3b82f6';
- const ctaBg = props.ctaBgColor || '#3b82f6';
- const ctaText = props.ctaTextColor || '#ffffff';
- const gap = props.gap || '24px';
+(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 breakout -> arbitrary ');
+ });
+
+ test('a backgroundColor value containing ');
+ });
+
+ test('a ctaColor value containing ');
+ });
+
+ 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');
+ });
+});
diff --git a/craft/src/components/basic/Navbar.tsx b/craft/src/components/basic/Navbar.tsx
index ac59b96..6c789c8 100644
--- a/craft/src/components/basic/Navbar.tsx
+++ b/craft/src/components/basic/Navbar.tsx
@@ -1,8 +1,8 @@
-import React, { CSSProperties, useCallback, useRef, useState } from 'react';
+import React, { CSSProperties, useState } from 'react';
import { useNode, UserComponent } from '@craftjs/core';
import { cssPropsToString } from '../../utils/style-helpers';
-import { usePages } from '../../state/PageContext';
import { useSiteDesign } from '../../state/SiteDesignContext';
+import { escapeHtml, escapeAttr, safeUrl, cssValue } from '../../utils/escape';
/* ---------- Types ---------- */
@@ -51,31 +51,6 @@ const PADDING_PRESETS = [
{ label: 'Spacious', value: '24px 48px' },
];
-/* ---------- Image upload helper (same as ImageBlock) ---------- */
-
-async function uploadToWhp(file: File): Promise {
- const cfg = (window as any).WHP_CONFIG;
- if (!cfg) return URL.createObjectURL(file);
- 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; }
-}
-
-/* ---------- Helper: escape HTML ---------- */
-function esc(str: any): string {
- str = String(str ?? "");
- return str.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"');
-}
-
/* ---------- Component ---------- */
export const Navbar: UserComponent = ({
@@ -199,595 +174,6 @@ export const Navbar: UserComponent = ({
);
};
-/* ---------- Settings panel ---------- */
-
-const NavbarSettings: React.FC = () => {
- const { actions: { setProp }, props } = useNode((node) => ({
- props: node.data.props as NavbarProps,
- }));
-
- const { pages } = usePages();
- const { design } = useSiteDesign();
-
- const links = props.links || defaultLinks;
- const logoType = props.logoType || 'text';
-
- const fileInputRef = useRef(null);
- const [showBrowser, setShowBrowser] = useState(false);
- const [browserAssets, setBrowserAssets] = useState([]);
- const [browserLoading, setBrowserLoading] = useState(false);
-
- /* Drag state for reordering */
- const [dragIdx, setDragIdx] = useState(null);
- const [dragOverIdx, setDragOverIdx] = useState(null);
-
- const bgPresets = ['#ffffff', '#f8fafc', '#f9fafb', '#18181b', '#0f172a', '#1e293b', '#1f2937', '#111827'];
- const textColorPresets = ['#1f2937', '#374151', '#3f3f46', '#6b7280', '#ffffff', '#e4e4e7', '#a1a1aa', '#3b82f6'];
- const fontFamilies = [
- { label: 'Inter', value: 'Inter, sans-serif' },
- { label: 'Roboto', value: 'Roboto, 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' },
- ];
-
- /* ---- Link management ---- */
-
- const updateLink = (index: number, field: keyof NavLink, value: string | boolean) => {
- setProp((p: NavbarProps) => {
- const updated = [...(p.links || defaultLinks)];
- updated[index] = { ...updated[index], [field]: value };
- p.links = updated;
- });
- };
-
- const addLink = (link?: Partial) => {
- setProp((p: NavbarProps) => {
- p.links = [...(p.links || defaultLinks), { text: 'Link', href: '#', ...link }];
- });
- };
-
- const removeLink = (index: number) => {
- setProp((p: NavbarProps) => {
- const updated = [...(p.links || defaultLinks)];
- updated.splice(index, 1);
- p.links = updated;
- });
- };
-
- const moveLink = (fromIdx: number, toIdx: number) => {
- if (fromIdx === toIdx) return;
- setProp((p: NavbarProps) => {
- const updated = [...(p.links || defaultLinks)];
- const [moved] = updated.splice(fromIdx, 1);
- updated.splice(toIdx, 0, moved);
- p.links = updated;
- });
- };
-
- /* ---- Image upload for logo ---- */
-
- const handleLogoUpload = useCallback(async (file: File) => {
- const url = await uploadToWhp(file);
- if (url) setProp((p: NavbarProps) => { p.logoImage = url; });
- }, [setProp]);
-
- const handleBrowse = useCallback(async () => {
- if (showBrowser) { setShowBrowser(false); return; }
- const cfg = (window as any).WHP_CONFIG;
- if (!cfg) return;
- setBrowserLoading(true);
- try {
- const resp = await fetch(`${cfg.apiUrl}?action=list_assets&site_id=${cfg.siteId}`);
- const data = await resp.json();
- if (data.success && Array.isArray(data.assets)) {
- const images = data.assets.filter((a: any) => (a.type || '').startsWith('image'));
- setBrowserAssets(images);
- setShowBrowser(true);
- }
- } catch (e) {
- console.error('Browse failed:', e);
- } finally {
- setBrowserLoading(false);
- }
- }, [showBrowser]);
-
- /* ---- Shared styles ---- */
-
- const labelStyle: CSSProperties = { fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 4 };
- const inputStyle: CSSProperties = {
- width: '100%', padding: '3px 6px', background: '#27272a', color: '#e4e4e7',
- border: '1px solid #3f3f46', borderRadius: 4, fontSize: 11,
- };
- const sectionStyle: CSSProperties = {
- borderBottom: '1px solid #27272a', paddingBottom: 12,
- };
- const swatchStyle = (color: string, active: boolean): CSSProperties => ({
- width: 22, height: 22, borderRadius: 4, border: '1px solid #3f3f46',
- backgroundColor: color, cursor: 'pointer',
- outline: active ? '2px solid #3b82f6' : 'none',
- outlineOffset: 1,
- });
- const btnSmall: CSSProperties = {
- padding: '2px 6px', fontSize: 11, background: '#27272a', color: '#a1a1aa',
- border: '1px solid #3f3f46', borderRadius: 4, cursor: 'pointer',
- };
- const btnActive: CSSProperties = {
- ...btnSmall, background: '#3b82f6', color: '#fff', borderColor: '#3b82f6',
- };
-
- return (
-
-
- {/* ===== Logo Section ===== */}
-
-
-
- {/* Logo type toggle */}
-
-
-
-
-
- {logoType === 'text' ? (
- <>
- {/* Text logo controls */}
-
-
- setProp((p: NavbarProps) => { p.logoText = e.target.value; })}
- style={inputStyle}
- />
-
-
-
-
-
-
-
-
- setProp((p: NavbarProps) => { p.logoFontSize = e.target.value; })}
- placeholder="20px"
- style={inputStyle}
- />
-
-
-
-
- setProp((p: NavbarProps) => { p.logoColor = e.target.value; })}
- style={{ width: 28, height: 24, padding: 0, border: '1px solid #3f3f46', borderRadius: 3, cursor: 'pointer', background: 'none' }}
- />
-
-
-
-
- >
- ) : (
- <>
- {/* Image logo controls */}
- {props.logoImage ? (
-
-

-
-
- ) : (
-
fileInputRef.current?.click()}
- onDragOver={(e) => { e.preventDefault(); e.currentTarget.style.borderColor = '#3b82f6'; }}
- onDragLeave={(e) => { e.currentTarget.style.borderColor = '#3f3f46'; }}
- onDrop={async (e) => {
- e.preventDefault();
- e.currentTarget.style.borderColor = '#3f3f46';
- const file = e.dataTransfer.files?.[0];
- if (file && file.type.startsWith('image/')) await handleLogoUpload(file);
- }}
- >
-
- Drop logo or click to upload
-
- )}
-
-
-
-
-
-
- {/* Browse grid */}
- {showBrowser && (
-
- {browserAssets.map(asset => (
-
{ setProp((p: NavbarProps) => { p.logoImage = asset.url; }); setShowBrowser(false); }}
- style={{ cursor: 'pointer', borderRadius: 4, overflow: 'hidden', border: '2px solid transparent', aspectRatio: '1' }}
- onMouseEnter={(e) => { e.currentTarget.style.borderColor = '#3b82f6'; }}
- onMouseLeave={(e) => { e.currentTarget.style.borderColor = 'transparent'; }}
- >
-

-
- ))}
- {browserAssets.length === 0 && (
-
No images uploaded yet.
- )}
-
- )}
-
-
{ const file = e.target.files?.[0]; if (file) handleLogoUpload(file); e.target.value = ''; }} />
-
- {/* URL input */}
-
- setProp((p: NavbarProps) => { p.logoImage = e.target.value; })}
- placeholder="Or paste image URL..."
- style={{ ...inputStyle, fontSize: 10, color: '#71717a' }}
- />
-
-
-
-
- setProp((p: NavbarProps) => { p.logoWidth = e.target.value; })}
- placeholder="120px"
- style={inputStyle}
- />
-
- >
- )}
-
- {/* Logo link URL (shared) */}
-
-
- setProp((p: NavbarProps) => { p.logoUrl = e.target.value; })}
- placeholder="/"
- style={inputStyle}
- />
-
-
-
- {/* ===== Nav Style Section ===== */}
-
-
-
- {/* Background color */}
-
-
-
- {bgPresets.map((c) => (
-
-
-
- {/* Text color */}
-
-
-
- {textColorPresets.map((c) => (
-
-
-
- {/* Link hover color */}
-
-
-
- setProp((p: NavbarProps) => { p.hoverColor = e.target.value; })}
- style={{ width: 28, height: 24, padding: 0, border: '1px solid #3f3f46', borderRadius: 3, cursor: 'pointer', background: 'none' }}
- />
- {props.hoverColor || '#3b82f6'}
-
-
-
- {/* CTA button colors */}
-
-
-
-
- BG
- setProp((p: NavbarProps) => { p.ctaColor = e.target.value; })}
- style={{ display: 'block', width: 28, height: 20, padding: 0, border: '1px solid #3f3f46', borderRadius: 3, cursor: 'pointer', background: 'none' }}
- />
-
-
- Text
- setProp((p: NavbarProps) => { p.ctaTextColor = e.target.value; })}
- style={{ display: 'block', width: 28, height: 20, padding: 0, border: '1px solid #3f3f46', borderRadius: 3, cursor: 'pointer', background: 'none' }}
- />
-
-
-
-
- {/* Padding presets */}
-
-
-
- {PADDING_PRESETS.map((p) => (
-
- ))}
-
-
-
- {/* Alignment */}
-
-
-
- {(['left', 'center', 'right', 'space-between'] as const).map((a) => (
-
- ))}
-
-
-
- {/* Sticky toggle */}
-
-
-
-
-
- {/* Design token quick apply */}
-
-
-
-
-
-
-
-
-
- {/* ===== Links Section ===== */}
-
-
-
-
- {links.map((link, i) => (
-
setDragIdx(i)}
- onDragOver={(e) => { e.preventDefault(); setDragOverIdx(i); }}
- onDragEnd={() => {
- if (dragIdx !== null && dragOverIdx !== null) {
- moveLink(dragIdx, dragOverIdx);
- }
- setDragIdx(null);
- setDragOverIdx(null);
- }}
- style={{
- background: dragOverIdx === i && dragIdx !== null && dragIdx !== i ? '#1e293b' : '#1e1e22',
- borderRadius: 6,
- padding: 8,
- display: 'flex',
- flexDirection: 'column',
- gap: 4,
- border: dragOverIdx === i && dragIdx !== null && dragIdx !== i ? '1px solid #3b82f6' : '1px solid transparent',
- transition: 'background 0.1s, border-color 0.1s',
- }}
- >
- {/* Row 1: drag handle + text + delete */}
-
-
-
-
- updateLink(i, 'text', e.target.value)}
- placeholder="Text"
- style={{ ...inputStyle, flex: 1 }}
- />
-
-
-
- {/* Row 2: URL */}
-
updateLink(i, 'href', e.target.value)}
- placeholder="URL (e.g. /about or https://...)"
- style={inputStyle}
- />
-
- {/* Row 3: checkboxes */}
-
-
-
-
-
- ))}
-
-
- {/* Add link button */}
-
-
- {/* Add page dropdown */}
-
-
-
- );
-};
-
/* ---------- Craft config ---------- */
Navbar.craft = {
@@ -820,20 +206,20 @@ Navbar.craft = {
canMoveIn: () => false,
canMoveOut: () => true,
},
- related: {
- settings: NavbarSettings,
- },
};
/* ---------- HTML export ---------- */
(Navbar as any).toHtml = (props: NavbarProps, _childrenHtml: string) => {
- const bgColor = props.backgroundColor || '#ffffff';
- const textCol = props.textColor || '#3f3f46';
- const hoverCol = props.hoverColor || '#3b82f6';
- const ctaCol = props.ctaColor || '#3b82f6';
- const ctaTextCol = props.ctaTextColor || '#ffffff';
- const pad = props.padding || '16px 24px';
+ // Sanitized once here -- these are raw string-interpolation sinks below
+ // (hoverCol/bgColor go into a
+ // breakout -> arbitrary ';
+ const { html } = toHtml({ buttonText: malicious, showButton: true }, '');
+ expect(html).not.toContain('');
+ });
+
+ 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)/);
+ });
+});
diff --git a/craft/src/components/basic/SearchBar.tsx b/craft/src/components/basic/SearchBar.tsx
index ad752d6..78030a1 100644
--- a/craft/src/components/basic/SearchBar.tsx
+++ b/craft/src/components/basic/SearchBar.tsx
@@ -1,6 +1,7 @@
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;
@@ -92,62 +93,6 @@ export const SearchBar: UserComponent = ({
);
};
-/* ---------- Settings panel ---------- */
-
-const SearchBarSettings: React.FC = () => {
- const { actions: { setProp }, props } = useNode((node) => ({
- props: node.data.props as SearchBarProps,
- }));
-
- const labelStyle: CSSProperties = { fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 };
- const inputStyle: CSSProperties = {
- width: '100%', padding: '4px 8px', background: '#27272a', color: '#e4e4e7',
- border: '1px solid #3f3f46', borderRadius: 4, fontSize: 12,
- };
-
- return (
-
- );
-};
-
/* ---------- Craft config ---------- */
SearchBar.craft = {
@@ -163,15 +108,11 @@ SearchBar.craft = {
canMoveIn: () => false,
canMoveOut: () => true,
},
- related: {
- settings: SearchBarSettings,
- },
};
/* ---------- HTML export ---------- */
(SearchBar as any).toHtml = (props: SearchBarProps, _childrenHtml: string) => {
- const esc = (s: any) => String(s ?? "").replace(//g, '>').replace(/"/g, '"');
const {
placeholder = 'Search...',
buttonText = 'Search',
@@ -189,14 +130,14 @@ SearchBar.craft = {
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
- ? ``
+ ? ``
: '';
return {
html: ``,
diff --git a/craft/src/components/basic/SocialLinks.toHtml.test.ts b/craft/src/components/basic/SocialLinks.toHtml.test.ts
new file mode 100644
index 0000000..85aa97a
--- /dev/null
+++ b/craft/src/components/basic/SocialLinks.toHtml.test.ts
@@ -0,0 +1,53 @@
+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(/]*aria-label="Facebook"/);
+ });
+
+ test('the icon glyph itself is aria-hidden', () => {
+ const { html } = toHtml({ links: [{ platform: 'twitter', url: '#' }] }, '');
+ expect(html).toMatch(/
' }, '');
+ expect(html).not.toContain('
{
+ 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('Hello world
');
+ });
+});
diff --git a/craft/src/components/basic/TextBlock.tsx b/craft/src/components/basic/TextBlock.tsx
index eacc1c9..f6c6d26 100644
--- a/craft/src/components/basic/TextBlock.tsx
+++ b/craft/src/components/basic/TextBlock.tsx
@@ -1,9 +1,7 @@
import React, { CSSProperties, useCallback, useRef, useEffect } from 'react';
import { useNode, UserComponent } from '@craftjs/core';
import { cssPropsToString } from '../../utils/style-helpers';
-import { SettingsTabs } from '../../ui/SettingsTabs';
-import { TypographyControl } from '../../ui/TypographyControl';
-import { AdvancedTab } from '../../ui/AdvancedTab';
+import { escapeHtml } from '../../utils/escape';
interface TextBlockProps {
text?: string;
@@ -75,58 +73,6 @@ export const TextBlock: UserComponent = ({
);
};
-/* ---------- Settings panel ---------- */
-
-const TextBlockSettings: React.FC = () => {
- const { actions: { setProp }, props } = useNode((node) => ({
- props: node.data.props as TextBlockProps,
- }));
-
- return (
-
-
-
-
-
- }
- style={
- setProp((p: TextBlockProps) => { p.style = { ...p.style, ...updates }; })}
- />
- }
- advanced={
- setProp((p: TextBlockProps) => { p.style = { ...p.style, ...updates }; })}
- cssId={props.cssId || ''}
- onCssIdChange={(id) => setProp((p: TextBlockProps) => { p.cssId = id; })}
- cssClass={props.cssClass || ''}
- onCssClassChange={(cls) => setProp((p: TextBlockProps) => { p.cssClass = cls; })}
- hideOnDesktop={props.hideOnDesktop}
- onHideOnDesktopChange={(v) => setProp((p: TextBlockProps) => { p.hideOnDesktop = v; })}
- hideOnTablet={props.hideOnTablet}
- onHideOnTabletChange={(v) => setProp((p: TextBlockProps) => { p.hideOnTablet = v; })}
- hideOnMobile={props.hideOnMobile}
- onHideOnMobileChange={(v) => setProp((p: TextBlockProps) => { p.hideOnMobile = v; })}
- animation={props.animation}
- onAnimationChange={(v) => setProp((p: TextBlockProps) => { p.animation = v; })}
- animationDelay={props.animationDelay}
- onAnimationDelayChange={(v) => setProp((p: TextBlockProps) => { p.animationDelay = v; })}
- />
- }
- />
- );
-};
-
/* ---------- Craft config ---------- */
TextBlock.craft = {
@@ -144,15 +90,12 @@ TextBlock.craft = {
canMoveIn: () => false,
canMoveOut: () => true,
},
- related: {
- settings: TextBlockSettings,
- },
};
/* ---------- HTML export ---------- */
(TextBlock as any).toHtml = (props: TextBlockProps, _childrenHtml: string) => {
const styleStr = cssPropsToString(props.style);
- const escapedText = (props.text || '').replace(//g, '>');
+ const escapedText = escapeHtml(props.text || '');
return { html: `${escapedText}
` };
};
diff --git a/craft/src/components/forms/ContactForm.toHtml.test.ts b/craft/src/components/forms/ContactForm.toHtml.test.ts
index 36e48f3..42cd974 100644
--- a/craft/src/components/forms/ContactForm.toHtml.test.ts
+++ b/craft/src/components/forms/ContactForm.toHtml.test.ts
@@ -6,12 +6,12 @@ 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(//);
- expect(html).toMatch(/action="__WHP_FORM_ACTION__F[0-9a-z]+__"/);
+ expect(html).toMatch(//);
+ 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];
+ const mid = html.match(/id="(F_[0-9a-z]+)"/)![1];
expect(html).toContain(`__WHP_FORM_ACTION__${mid}__`);
});
@@ -41,3 +41,88 @@ describe('ContactForm.toHtml relay wiring', () => {
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(//);
- expect(html).toMatch(/action="__WHP_FORM_ACTION__F[0-9a-z]+__"/);
+ expect(html).toMatch(//);
+ 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];
+ const mid = html.match(/id="(F_[0-9a-z]+)"/)![1];
expect(html).toContain(`__WHP_FORM_ACTION__${mid}__`);
});
@@ -24,4 +24,31 @@ describe('FormContainer.toHtml relay wiring', () => {
expect(html).toContain('action="/legacy"');
expect(html).toContain('');
});
+
+ test('same node id -> identical marker+placeholder ids across two calls', () => {
+ const { html: html1 } = toHtml({ recipientEmail: 'a@b.com', thankYouUrl: '/thx' }, '', 'node-fc1');
+ const { html: html2 } = toHtml({ recipientEmail: 'a@b.com', thankYouUrl: '/thx' }, '', 'node-fc1');
+ expect(html1).toBe(html2);
+ });
+
+ test('two different node ids -> different fids', () => {
+ const { html: html1 } = toHtml({ recipientEmail: 'a@b.com', thankYouUrl: '/thx' }, '', 'node-fc1');
+ const { html: html2 } = toHtml({ recipientEmail: 'a@b.com', thankYouUrl: '/thx' }, '', 'node-fc2');
+ const mid1 = html1.match(/`,
+ marker: ``,
actionAttr: `__WHP_FORM_ACTION__${fid}__`,
honeypot: ``,
};
diff --git a/craft/src/utils/html-export.test.ts b/craft/src/utils/html-export.test.ts
new file mode 100644
index 0000000..ba92cde
--- /dev/null
+++ b/craft/src/utils/html-export.test.ts
@@ -0,0 +1,99 @@
+import { describe, test, expect } from 'vitest';
+import { exportBodyHtml } from './html-export';
+
+/**
+ * C2: buildDataAttrs (internal to html-export.ts) previously interpolated
+ * `props.animation` / `props.animationDelay` directly into
+ * `data-animation="${...}"` / `data-animation-delay="${...}"` with no
+ * escaping. Runs for EVERY node (via renderNode -> buildDataAttrs ->
+ * injectAttrs) and is reachable via AI `update_props` + deserialized saved
+ * state. A malicious value like `x">
` breaks
+ * out of the attribute and injects a live element.
+ */
+describe('buildDataAttrs escapes animation/animationDelay (C2)', () => {
+ const makeState = (props: Record) =>
+ JSON.stringify({
+ ROOT: {
+ type: { resolvedName: 'Container' },
+ isCanvas: true,
+ props: { tag: 'div', style: {}, ...props },
+ displayName: 'Container',
+ custom: {},
+ hidden: false,
+ nodes: [],
+ linkedNodes: {},
+ },
+ });
+
+ test('malicious animation value is escaped -- no raw breakout, no injected
', () => {
+ const payload = 'x">
';
+ const state = makeState({ animation: payload });
+ const { html } = exportBodyHtml(state);
+
+ expect(html).not.toContain('
');
+ expect(html).not.toMatch(/data-animation="x">/);
+ // The attribute value must be escaped, so no raw `"` or `<` survives
+ // inside the data-animation attribute region.
+ expect(html).toContain('data-animation="x"><img src=y onerror=alert(1)>"');
+ });
+
+ test('malicious animationDelay value is escaped -- no raw breakout', () => {
+ const payload = 'x">';
+ const state = makeState({ animation: 'fade-in', animationDelay: payload });
+ const { html } = exportBodyHtml(state);
+
+ expect(html).not.toContain('');
+ expect(html).toContain('data-animation-delay="x"><script>alert(1)</script>"');
+ });
+
+ test('normal animation value still emits data-animation="fade-in" unchanged', () => {
+ const state = makeState({ animation: 'fade-in' });
+ const { html } = exportBodyHtml(state);
+ expect(html).toContain('data-animation="fade-in"');
+ });
+
+ test('normal animationDelay value still emits unchanged', () => {
+ const state = makeState({ animation: 'fade-in', animationDelay: '0.5s' });
+ const { html } = exportBodyHtml(state);
+ expect(html).toContain('data-animation-delay="0.5s"');
+ });
+});
+
+/**
+ * Adversarial re-review of C1: the `typeName === 'Container' || typeName ===
+ * 'div'` fallback in renderNode (hit when a node's `resolvedName` isn't in
+ * the component resolver, e.g. legacy/tampered saved state) interpolated
+ * `props.tag` raw into `<${tag}` with no validation -- the same breakout
+ * class as the fixed Container.toHtml bug, reachable via deserialized saved
+ * state.
+ */
+describe('renderNode div-fallback allowlists props.tag', () => {
+ const makeUnresolvedDivState = (tag: unknown) =>
+ JSON.stringify({
+ ROOT: {
+ type: { resolvedName: 'div' }, // not in componentResolver -> hits the fallback branch
+ isCanvas: true,
+ props: { tag, style: {} },
+ displayName: 'div',
+ custom: {},
+ hidden: false,
+ nodes: [],
+ linkedNodes: {},
+ },
+ });
+
+ test('a malicious tag value falls back to div -- no injected
', () => {
+ const state = makeUnresolvedDivState('div>
{
+ const state = makeUnresolvedDivState('section');
+ const { html } = exportBodyHtml(state);
+ expect(html).toContain('');
+ });
+});
diff --git a/craft/src/utils/html-export.ts b/craft/src/utils/html-export.ts
index afef403..fcf06dd 100644
--- a/craft/src/utils/html-export.ts
+++ b/craft/src/utils/html-export.ts
@@ -1,5 +1,7 @@
import { componentResolver } from '../components/resolver';
import { cssPropsToString } from './style-helpers';
+import { escapeHtml, escapeAttr } from './escape';
+import { sanitizeContainerTag } from '../components/layout/Container';
export interface ExportOptions {
title?: string;
@@ -23,9 +25,9 @@ function buildDataAttrs(props: Record): string {
if (props.hideOnTablet) attrs += ' data-hide-tablet';
if (props.hideOnMobile) attrs += ' data-hide-mobile';
if (props.animation && props.animation !== 'none') {
- attrs += ` data-animation="${props.animation}"`;
+ attrs += ` data-animation="${escapeAttr(String(props.animation))}"`;
if (props.animationDelay && props.animationDelay !== '0') {
- attrs += ` data-animation-delay="${props.animationDelay}"`;
+ attrs += ` data-animation-delay="${escapeAttr(String(props.animationDelay))}"`;
}
}
return attrs;
@@ -73,10 +75,19 @@ function renderNode(nodes: Record, nodeId: string): { html: string
// Build data attributes for responsive visibility and animations
const dataAttrs = buildDataAttrs(props);
- // Look up component in resolver and call toHtml
+ // Look up component in resolver and call toHtml. `nodeId` is the Craft.js
+ // node id for this node -- unique per node in the tree and stable across
+ // repeated exports of the same saved page. It's passed as a 3rd argument
+ // so components can derive deterministic AND unique scope ids for their
+ // exported element ids / aria-wiring / inline-script function names
+ // (see `scopeId` in utils/escape.ts) instead of Math.random() (unique but
+ // non-deterministic) or a content hash (deterministic but collides when
+ // two instances share default/identical content). Components that don't
+ // need a scope id simply ignore the extra argument -- backward compatible
+ // with existing 2-arg `toHtml(props, childrenHtml)` implementations/tests.
const component = resolver[typeName];
if (component && typeof component.toHtml === 'function') {
- const result = component.toHtml(props, allChildrenHtml);
+ const result = component.toHtml(props, allChildrenHtml, nodeId);
const html = result.html || '';
return { html: injectAttrs(html, dataAttrs) };
}
@@ -84,7 +95,11 @@ function renderNode(nodes: Record, nodeId: string): { html: string
// Fallback: wrap children in a div with inline styles
if (typeName === 'Container' || typeName === 'div') {
const styleStr = cssPropsToString(props.style);
- const tag = props.tag || 'div';
+ // `props.tag` reaches this fallback the same way it reaches
+ // `Container.toHtml` -- via AI `update_props` or deserialized saved
+ // state, neither type-checked at runtime -- so it must be allowlisted
+ // before hitting the `<${tag}` template position below.
+ const tag = sanitizeContainerTag(props.tag);
return {
html: `<${tag}${dataAttrs}${styleStr ? ` style="${styleStr}"` : ''}>${allChildrenHtml}${tag}>`,
};
@@ -198,10 +213,6 @@ ${bodyHtml}${animationScript}