Compare commits

..
Author SHA1 Message Date
shadowdao 72f85a97e5 fix(builder): phase-1 polish a11y follow-ups (dropzone keyboard, badge name, icon aria) 2026-07-12 20:22:11 -07:00
shadowdaoandClaude Opus 4.8 ab28ad8f2c Merge assets-panel empty state into one dropzone
The Assets panel used to show a small always-visible dropzone plus a
separate italic "No assets uploaded yet" line stacked underneath it
when there were no assets -- two redundant messages for one state.
Replace both with a single tall dropzone (icon + "Drag images here or
click to upload") that also opens the file picker on click; it
collapses back to the original slim "Drop files here to upload" bar
once assets exist. Upload/drag-drop behavior is unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 20:15:15 -07:00
shadowdaoandClaude Opus 4.8 05e00c572d Show component-indicator selection badge in the canvas
.component-indicator existed in editor.css but was never rendered
anywhere. Add RenderNode.tsx as a Craft.js <Editor onRender> override
and wire it in App.tsx: for the currently-selected node (excluding
ROOT) it portals a floating badge showing the node's displayName plus
a "select parent" chevron wired to actions.selectNode(parentId). Every
other node's render passes through untouched (a Fragment, no extra
DOM), and the badge portals to document.body positioned via
getBoundingClientRect rather than wrapping nodes in extra DOM, so it
can't perturb canvas layout and never appears in toHtml export.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 20:15:08 -07:00
shadowdaoandClaude Opus 4.8 458069afb6 Show empty-canvas hint on a page with no components yet
.empty-canvas-hint existed in editor.css but was never rendered
anywhere. Wire it up in Canvas.tsx: an EmptyCanvasHint component reads
Craft's ROOT node via useEditor and shows the hint once ROOT exists
with zero children, hiding again the instant something is dropped in
or while a drag is in progress. It's absolutely positioned over the
Frame with pointer-events: none so it never intercepts clicks/drops
meant for the underlying (empty) canvas -- scoped to regular page
editing only, not the header/footer editing mode.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 20:14:58 -07:00
shadowdaoandClaude Opus 4.8 b3e5009aec Fix preset-grid orphan row for 5/6-item preset sets
PresetButtonGrid rendered every preset set into a fixed 4-column
.preset-grid, so 5-item sets (RADIUS_PRESETS, SPACING_PRESETS,
IMAGE_RADIUS_PRESETS, FONT_WEIGHTS, NavStylePanel's GAP_PRESETS)
wrapped a single lone button onto its own row, and the 6-item
TEXT_SIZES split unevenly (4+2).

PresetButtonGrid now derives a column count from presets.length via
defaultPresetGridColumns() -- 5-item sets get a single row of 5,
6-item sets split into two even rows of 3, and anything else keeps
the classic 4-column grid -- with an optional `columns` prop for
explicit overrides. This fixes every existing call site automatically
rather than threading an explicit count through each one.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 20:14:38 -07:00
shadowdaoandClaude Opus 4.8 eeb0660d83 Replace emoji-as-icons in editor chrome with Font Awesome
Unicode emoji/glyphs (Sitesmith's sparkle, lock, close X) render as
tofu on systems without an emoji font. Swap for the FA4 glyphs the
rest of the chrome already uses:
- SitesmithButton/SitesmithModal: sparkle -> fa-magic, lock -> fa-lock
- ContextMenu "Ask Sitesmith" entry: sparkle -> fa-magic (via new
  optional MenuItem.icon field)
- TemplateModal/SitesmithModal close buttons, PagesPanel delete,
  AssetsPanel delete/cancel: &#10005; -> fa-times / fa-trash

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 20:14:29 -07:00
shadowdaoandClaude Opus 4.8 138e1a8273 Bump muted/dim text contrast and block label legibility
- --color-text-muted #71717a -> #8b8b96 (~4.9:1 on surface)
- --color-text-dim #52525b -> #6e6e78 (~3.3:1, decorative-only text)
- .block-item-label 10px -> 11px, .block-item-icon 18px -> 20px for
  hierarchy in the Blocks panel tile grid

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 20:14:10 -07:00
jknapp 1b12b79a0d Merge PR #5: image placeholder + Gallery/safeImageUrl fix 2026-07-13 02:54:24 +00:00
shadowdao 621bb21d52 fix(builder): safeImageUrl for FeaturesGrid/ContentSlider image sinks + tighten data:image allowlist
FeaturesGrid's <img src> and ContentSlider's CSS background-image url()
were still on safeUrl, which blocks data:image/svg+xml -- inconsistent
with other image sinks already swapped to safeImageUrl and a latent
regression for those two components. Swapped both to safeImageUrl;
left their navigation sinks (buttonUrl/buttonHref) on safeUrl.

Also tightened safeImageUrl's data:image allowlist check to require the
slash (dataimage/ not dataimage), so a bogus MIME like
data:imagehtml/... can no longer slip past the prefix check.
2026-07-12 19:53:02 -07:00
shadowdaoandClaude Opus 4.8 3f3c6fb851 security: add safeImageUrl, un-break M-5's over-blocking of image-context SVG data URIs
M-5 made safeUrl() block data:image/svg+xml everywhere, including the
image-only sinks (<img src>, CSS url()) that Gallery's default images and
other SVG placeholders rely on. Loaded as an image, an SVG is rasterized
and never executes an inline <script>/onload= -- that only happens when
it's navigated to or loaded as an <iframe> document -- so M-5 over-blocked
the safe contexts and broke every published Gallery (and other components
using an SVG placeholder) using safeUrl's default images in prod.

Adds safeImageUrl(): identical javascript:/vbscript: handling to safeUrl,
but treats data: as an allowlist of image/* subtypes instead of a
blocklist -- allows all data:image/* (including svg+xml, with or without
base64), still blocks data:text/html and any other non-image data: type.

Swapped to safeImageUrl at IMAGE-src / CSS-image url() sinks only:
- Gallery.tsx img src + lightbox data-lb-src
- ImageBlock.tsx img src (toHtml)
- Logo.tsx / Navbar.tsx logo <img> src (their href/link targets keep safeUrl)
- style-helpers.ts sanitizeCssValue's url(...) handling (background-image
  for HeroSimple/BackgroundSection/Section/CallToAction)

Left on safeUrl (href/iframe/form-action/navigation sinks, where
data:image/svg+xml must stay blocked): ButtonLink, Icon link, SocialLinks,
Menu/Navbar link hrefs, PricingTable buttonHref, _cta-helpers,
ContentSlider buttonHref, FeaturesGrid buttonUrl, FormContainer action
(via form-relay-wiring), MapEmbed/VideoBlock iframe src.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 19:45:58 -07:00
shadowdaoandClaude Opus 4.8 802938ec1a fix(builder): Image block placeholder no longer overridden by explicit src=""
BlocksPanel dropped a new Image block with an explicit `src=""` prop, which
overrides ImageBlock's `src = PLACEHOLDER_SRC` default parameter (defaults
only apply when a prop is undefined, not when it's an empty string). Craft
then persisted `src:''`, and the canvas rendered a broken-image icon instead
of the placeholder.

- ImageBlock render now falls back to PLACEHOLDER_SRC whenever src is falsy
  (belt-and-braces: also recovers any legacy saved src:'' state).
- BlocksPanel no longer passes src="" when dropping a new Image block, so
  the craft default applies.
- ImageStylePanel now restores the placeholder (instead of blanking to '')
  when the URL field is cleared.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 19:45:28 -07:00
jknapp 3e43aee6e9 Merge PR #4: head code to published pages + adversarial-review Minors 2026-07-13 01:37:13 +00:00
shadowdaoandClaude Opus 4.8 bf4a9f48eb security: block data:image/svg+xml + sandbox HtmlBlock iframes
M-5: safeUrl() blocked javascript:/vbscript:/data:text/html but
allowed data:image/svg+xml, which can execute inline <script>/onload=
when loaded as a document/navigation target despite its "image" MIME
type (defense in depth -- not currently reachable to execution via
this sink, but closing it). Added `data:image/svg+xml` to the existing
DANGEROUS_SCHEME_PREFIXES check, so it's caught after the same
entity-decode/whitespace-strip/lowercase normalization used for the
other blocked schemes (obfuscated variants included). Other
data:image/* types (png/jpeg/gif/webp, ...) remain allowed unchanged.

M-6: HtmlBlock's purifyHtml() allowed <iframe src> through with no
`sandbox` attribute -- a clickjacking/phishing vector even with
DOMPurify already stripping script/on*=. Added a DOMPurify
afterSanitizeAttributes hook, scoped tightly to each purifyHtml() call
(added right before sanitize(), removed in a finally right after) so
it can't leak onto other DOMPurify uses or accumulate duplicates
across repeated calls, that force-sets a restrictive sandbox
(allow-scripts allow-same-origin allow-popups allow-forms -- no
allow-top-navigation) and referrerpolicy=no-referrer on every iframe
that survives sanitization.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 18:31:25 -07:00
shadowdaoandClaude Opus 4.8 86455413d0 fix: unique addPage ids + collision-free scopeId hashing
M-3: PageContext.addPage minted ids from bare `page_${Date.now()}` --
two adds inside the same millisecond collided on id, so a subsequent
rename/delete/save silently acted on both pages at once. Added a
module-scoped monotonic counter combined with the timestamp
(nextPageId(), exported for direct unit testing) and used it
everywhere an addPage-style id is minted (addPage, replaceAllPages).

M-4: scopeId() lowercased + stripped non-alphanumeric characters from
the node id into a slug, so two node ids differing only by
case/punctuation (e.g. "AbC" vs "abc", or "a-b" vs "ab") collapsed
onto the same scope -- defeating the whole point of scoping ids per
node (M-1/Menu/Tabs/ColumnLayout/Gallery/etc. all rely on it). Now
hashes the raw node id via the existing djb2 stableHash() instead of
slugifying it: still deterministic (same id -> same scope) and a valid
CSS ident, but collision-resistant across case/punctuation. This
changes the exact scope strings Menu/Tabs/ColumnLayout/Gallery/etc.
emit -- expected and fine, since none of their tests pinned an exact
scope value (all already asserted structure/uniqueness).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 18:31:12 -07:00
shadowdaoandClaude Opus 4.8 0cbc58f8d1 a11y/security: scope Navbar ids + hover styles, Gallery lightbox focus trap
M-1: Navbar.toHtml emitted a fixed id="navbar-links" and unscoped
.navbar-link/.navbar-cta :hover selectors -- two Navbars on one page
collided on the duplicate id and cross-applied each other's hover
colors (later <style> block wins in the cascade). Scope both on the
Craft node id via scopeId(), matching the Menu/Tabs pattern: the links
container gets a unique id, aria-controls/the hamburger toggle script
reference it, and the hover rules are prefixed with a per-instance
class on the <nav> root.

M-2: Gallery lightbox had no focus management -- opening it left focus
wherever it was (behind the now-visible overlay) and closing it never
restored it. The inline script now stashes document.activeElement on
open, moves focus to a new accessible close button, traps Tab on the
close button while the dialog is open, and restores the saved focus on
close (Escape, backdrop click, or the close button).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 18:30:40 -07:00
shadowdaoandClaude Opus 4.8 92841e3f35 feat(builder): send + restore site head code in save/load
Extend the save payload with head_code + design so the backend can
inject SiteDesign.headCode into published pages, and restore design
tokens on load() so the editor reflects the last-saved state.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 18:22:38 -07:00
jknappandClaude Opus 4.8 e892ee0e53 Merge PR #3: site builder security & data-loss hardening + asset picker + audit backlog
51 impl commits + 10 adversarial-review fix commits. All 339→502 tests green; final adversarial XSS pass: PASS.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 01:13:27 +00:00
36 changed files with 1073 additions and 124 deletions
+2 -1
View File
@@ -1,6 +1,7 @@
import React from 'react'; import React from 'react';
import { Editor } from '@craftjs/core'; import { Editor } from '@craftjs/core';
import { EditorShell } from './editor/EditorShell'; import { EditorShell } from './editor/EditorShell';
import { RenderNode } from './editor/RenderNode';
import { componentResolver } from './components/resolver'; import { componentResolver } from './components/resolver';
import { WhpConfig } from './types'; import { WhpConfig } from './types';
import { EditorConfigProvider } from './state/EditorConfigContext'; import { EditorConfigProvider } from './state/EditorConfigContext';
@@ -23,7 +24,7 @@ export const App: React.FC<AppProps> = ({ whpConfig }) => {
return ( return (
<EditorConfigProvider config={whpConfig}> <EditorConfigProvider config={whpConfig}>
<SiteDesignProvider> <SiteDesignProvider>
<Editor resolver={componentResolver} enabled={true}> <Editor resolver={componentResolver} enabled={true} onRender={RenderNode}>
<PageProvider> <PageProvider>
<SitesmithProvider> <SitesmithProvider>
<EditorShell /> <EditorShell />
@@ -21,3 +21,46 @@ describe('purifyHtml', () => {
expect(purifyHtml('<form><input name="x"></form>')).not.toContain('<form'); 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>');
});
});
+28
View File
@@ -24,14 +24,42 @@ const PURIFY_CONFIG = {
'href','src','alt','title','target','rel', 'href','src','alt','title','target','rel',
'width','height','class', 'width','height','class',
'allowfullscreen','allow','frameborder', 'allowfullscreen','allow','frameborder',
'sandbox','referrerpolicy',
], ],
ALLOWED_URI_REGEXP: /^(?:(?:https?|mailto|tel|data:image\/[a-z]+;base64,):|[^a-z]|[a-z+.-]+(?:[^a-z+.\-:]|$))/i, 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_TAGS: ['script','style','object','embed','link','meta','form','input','button','select','textarea'],
FORBID_ATTR: [/^on/i], 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 { 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; 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 = {} }) => { export const HtmlBlock: UserComponent<HtmlBlockProps> = ({ code = '', style = {} }) => {
+2 -2
View File
@@ -2,7 +2,7 @@ import React, { CSSProperties } from 'react';
import { useNode, UserComponent } from '@craftjs/core'; import { useNode, UserComponent } from '@craftjs/core';
import { cssPropsToString } from '../../utils/style-helpers'; import { cssPropsToString } from '../../utils/style-helpers';
import { useSiteDesign } from '../../state/SiteDesignContext'; import { useSiteDesign } from '../../state/SiteDesignContext';
import { escapeHtml, escapeAttr, safeUrl } from '../../utils/escape'; import { escapeHtml, escapeAttr, safeUrl, safeImageUrl } from '../../utils/escape';
/* ---------- Types ---------- */ /* ---------- Types ---------- */
@@ -104,7 +104,7 @@ Logo.craft = {
let innerHtml: string; let innerHtml: string;
if (props.type === 'image' && props.imageSrc) { if (props.type === 'image' && props.imageSrc) {
const imgStyle = cssPropsToString({ width: props.imageWidth || '120px', height: 'auto', display: 'block' }); const imgStyle = cssPropsToString({ width: props.imageWidth || '120px', height: 'auto', display: 'block' });
innerHtml = `<img src="${escapeAttr(safeUrl(props.imageSrc))}" alt="${escapeAttr(props.text || 'Logo')}"${imgStyle ? ` style="${imgStyle}"` : ''} />`; innerHtml = `<img src="${escapeAttr(safeImageUrl(props.imageSrc))}" alt="${escapeAttr(props.text || 'Logo')}"${imgStyle ? ` style="${imgStyle}"` : ''} />`;
} else { } else {
const spanStyle = cssPropsToString({ const spanStyle = cssPropsToString({
fontWeight: props.fontWeight || '700', fontWeight: props.fontWeight || '700',
@@ -5,28 +5,84 @@ const toHtml = (Navbar as any).toHtml;
describe('Navbar.toHtml hamburger accessibility (F2.3)', () => { describe('Navbar.toHtml hamburger accessibility (F2.3)', () => {
test('mobile toggle button has an accessible name, aria-expanded, and aria-controls', () => { test('mobile toggle button has an accessible name, aria-expanded, and aria-controls', () => {
const { html } = toHtml({ showMobileMenu: true }, ''); const { html } = toHtml({ showMobileMenu: true }, '', 'node-nav1');
expect(html).toMatch(/class="navbar-hamburger"[^>]*aria-label="Toggle navigation menu"/); expect(html).toMatch(/class="navbar-hamburger"[^>]*aria-label="Toggle navigation menu"/);
expect(html).toMatch(/aria-expanded="false"/); expect(html).toMatch(/aria-expanded="false"/);
expect(html).toMatch(/aria-controls="navbar-links"/); expect(html).toMatch(/aria-controls="[^"]+"/);
}); });
test('aria-controls target id exists on the links container', () => { test('aria-controls target id exists on the links container', () => {
const { html } = toHtml({ showMobileMenu: true }, ''); const { html } = toHtml({ showMobileMenu: true }, '', 'node-nav1');
expect(html).toContain('id="navbar-links"'); const controls = html.match(/aria-controls="([^"]+)"/)![1];
expect(html).toContain(`id="${controls}"`);
}); });
test('toggle script flips aria-expanded on click', () => { test('toggle script flips aria-expanded on click', () => {
const { html } = toHtml({ showMobileMenu: true }, ''); const { html } = toHtml({ showMobileMenu: true }, '', 'node-nav1');
expect(html).toMatch(/setAttribute\(['"]aria-expanded['"]/); expect(html).toMatch(/setAttribute\(['"]aria-expanded['"]/);
}); });
test('no mobile menu: no hamburger button emitted', () => { test('no mobile menu: no hamburger button emitted', () => {
const { html } = toHtml({ showMobileMenu: false }, ''); const { html } = toHtml({ showMobileMenu: false }, '', 'node-nav1');
expect(html).not.toContain('navbar-hamburger'); 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>)', () => { describe('Navbar.toHtml XSS hardening (hoverColor/backgroundColor/ctaColor into <style>)', () => {
test('a hoverColor value containing </style><script> is neutralized in the hover <style> block', () => { test('a hoverColor value containing </style><script> is neutralized in the hover <style> block', () => {
const malicious = '#fff}</style><script>alert(1)</script><style>{'; const malicious = '#fff}</style><script>alert(1)</script><style>{';
+27 -12
View File
@@ -2,7 +2,7 @@ import React, { CSSProperties, useState } from 'react';
import { useNode, UserComponent } from '@craftjs/core'; import { useNode, UserComponent } from '@craftjs/core';
import { cssPropsToString } from '../../utils/style-helpers'; import { cssPropsToString } from '../../utils/style-helpers';
import { useSiteDesign } from '../../state/SiteDesignContext'; import { useSiteDesign } from '../../state/SiteDesignContext';
import { escapeHtml, escapeAttr, safeUrl, cssValue } from '../../utils/escape'; import { escapeHtml, escapeAttr, safeUrl, safeImageUrl, cssValue, scopeId } from '../../utils/escape';
/* ---------- Types ---------- */ /* ---------- Types ---------- */
@@ -210,7 +210,7 @@ Navbar.craft = {
/* ---------- HTML export ---------- */ /* ---------- HTML export ---------- */
(Navbar as any).toHtml = (props: NavbarProps, _childrenHtml: string) => { (Navbar as any).toHtml = (props: NavbarProps, _childrenHtml: string, nodeId?: string) => {
// Sanitized once here -- these are raw string-interpolation sinks below // Sanitized once here -- these are raw string-interpolation sinks below
// (hoverCol/bgColor go into a <style> block, the worst case: </style> // (hoverCol/bgColor go into a <style> block, the worst case: </style>
// breakout -> arbitrary <script>), see task-cssxss-brief.md. // breakout -> arbitrary <script>), see task-cssxss-brief.md.
@@ -224,6 +224,19 @@ Navbar.craft = {
const sticky = props.isSticky; const sticky = props.isSticky;
const mobile = props.showMobileMenu; const mobile = props.showMobileMenu;
const logoUrl = props.logoUrl || '/'; 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({ const navStyle = cssPropsToString({
display: 'flex', display: 'flex',
@@ -239,7 +252,7 @@ Navbar.craft = {
let logoHtml: string; let logoHtml: string;
if (props.logoType === 'image' && props.logoImage) { if (props.logoType === 'image' && props.logoImage) {
const imgStyle = cssPropsToString({ width: props.logoWidth || '120px', height: 'auto', display: 'block' }); 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(safeUrl(props.logoImage))}" alt="${escapeAttr(props.logoText || 'Logo')}"${imgStyle ? ` style="${imgStyle}"` : ''} /></a>`; 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 { } else {
const logoStyle = cssPropsToString({ const logoStyle = cssPropsToString({
fontWeight: '700', fontWeight: '700',
@@ -272,21 +285,23 @@ Navbar.craft = {
// via aria-expanded, kept in sync with the .navbar-open class by the // via aria-expanded, kept in sync with the .navbar-open class by the
// inline onclick handler. // inline onclick handler.
const hamburgerHtml = mobile const hamburgerHtml = mobile
? `\n <button class="navbar-hamburger" aria-label="Toggle navigation menu" aria-expanded="false" aria-controls="navbar-links" onclick="var m=this.parentElement.querySelector('.navbar-links');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"> ? `\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> <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>` </button>`
: ''; : '';
// Hover CSS // 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> const hoverCss = `<style>
.navbar-link:hover { color: ${hoverCol} !important; } .${scope} .navbar-link:hover { color: ${hoverCol} !important; }
.navbar-cta:hover { filter: brightness(1.1); }${mobile ? ` .${scope} .navbar-cta:hover { filter: brightness(1.1); }${mobile ? `
@media (max-width: 768px) { @media (max-width: 768px) {
.navbar-hamburger { display: flex !important; } .${scope} .navbar-hamburger { display: flex !important; }
.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 { 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); }
.navbar-links.navbar-open { display: flex !important; } .${scope} .navbar-links.navbar-open { display: flex !important; }
}` : ''} }` : ''}
</style>`; </style>`;
@@ -309,9 +324,9 @@ Navbar.craft = {
return { return {
html: `${hoverCss} html: `${hoverCss}
<nav${navStyle ? ` style="${navStyle}${mobile ? ';position:relative' : ''}"` : ''}> <nav class="${scope}"${navStyle ? ` style="${navStyle}${mobile ? ';position:relative' : ''}"` : ''}>
${logoHtml}${hamburgerHtml} ${logoHtml}${hamburgerHtml}
<div class="navbar-links" id="navbar-links" style="display:flex;align-items:center;gap:24px"> <div class="navbar-links" id="${linksId}" style="display:flex;align-items:center;gap:24px">
${linksHtmlWithClass} ${linksHtmlWithClass}
</div> </div>
</nav>`, </nav>`,
@@ -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();
});
});
+4 -4
View File
@@ -1,9 +1,9 @@
import React, { CSSProperties, useCallback, useRef } from 'react'; import React, { CSSProperties, useCallback, useRef } from 'react';
import { useNode, UserComponent } from '@craftjs/core'; import { useNode, UserComponent } from '@craftjs/core';
import { cssPropsToString } from '../../utils/style-helpers'; import { cssPropsToString } from '../../utils/style-helpers';
import { escapeAttr, safeUrl } from '../../utils/escape'; import { escapeAttr, safeImageUrl } from '../../utils/escape';
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"; 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 { interface ImageBlockProps {
src?: string; src?: string;
@@ -66,7 +66,7 @@ export const ImageBlock: UserComponent<ImageBlockProps> = ({
imgRef.current = ref; imgRef.current = ref;
if (ref) connect(drag(ref)); if (ref) connect(drag(ref));
}} }}
src={src} src={src || PLACEHOLDER_SRC}
alt={alt || 'Image'} alt={alt || 'Image'}
onDrop={handleDrop} onDrop={handleDrop}
onDragOver={handleDragOver} onDragOver={handleDragOver}
@@ -95,5 +95,5 @@ ImageBlock.craft = {
} }
const s = cssPropsToString({ display: 'block', maxWidth: '100%', ...props.style }); const s = cssPropsToString({ display: 'block', maxWidth: '100%', ...props.style });
const alt = props.alt ? ` alt="${escapeAttr(props.alt)}"` : ' alt=""'; const alt = props.alt ? ` alt="${escapeAttr(props.alt)}"` : ' alt=""';
return { html: `<img src="${escapeAttr(safeUrl(src))}"${alt}${s ? ` style="${s}"` : ''} />` }; return { html: `<img src="${escapeAttr(safeImageUrl(src))}"${alt}${s ? ` style="${s}"` : ''} />` };
}; };
@@ -109,6 +109,15 @@ describe('ContentSlider.toHtml renders slide.imageSrc as a background-image (INT
expect(html).not.toContain('background-image:url('); expect(html).not.toContain('background-image:url(');
expect(html).toContain('background-color:#123456'); 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', () => { describe('ContentSlider.toHtml interval is NOT runtime-type-checked -- must be coerced before it reaches the inline <script> numeric context', () => {
@@ -1,7 +1,7 @@
import React, { CSSProperties, useState, useEffect, useRef, useCallback } from 'react'; import React, { CSSProperties, useState, useEffect, useRef, useCallback } from 'react';
import { useNode, UserComponent } from '@craftjs/core'; import { useNode, UserComponent } from '@craftjs/core';
import { cssPropsToString } from '../../utils/style-helpers'; import { cssPropsToString } from '../../utils/style-helpers';
import { escapeHtml, escapeAttr, safeUrl, scopeId, cssValue } from '../../utils/escape'; import { escapeHtml, escapeAttr, safeUrl, safeImageUrl, scopeId, cssValue } from '../../utils/escape';
interface Slide { interface Slide {
type: 'image' | 'content'; type: 'image' | 'content';
@@ -283,7 +283,7 @@ ContentSlider.craft = {
// sink (a malicious value could break out of the style="..." attribute). // sink (a malicious value could break out of the style="..." attribute).
const safeBgColor = cssValue(slide.bgColor) || '#3b82f6'; const safeBgColor = cssValue(slide.bgColor) || '#3b82f6';
const bgStyle = hasBgImage const bgStyle = hasBgImage
? `background-image:url('${escapeAttr(safeUrl(slide.imageSrc!))}');background-size:cover;background-position:center` ? `background-image:url('${escapeAttr(safeImageUrl(slide.imageSrc!))}');background-size:cover;background-position:center`
: slide.bgColor?.startsWith('linear-gradient') : slide.bgColor?.startsWith('linear-gradient')
? `background-image:${safeBgColor}` ? `background-image:${safeBgColor}`
: `background-color:${safeBgColor}`; : `background-color:${safeBgColor}`;
@@ -0,0 +1,23 @@
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=""/);
});
});
@@ -1,7 +1,7 @@
import React, { CSSProperties } from 'react'; import React, { CSSProperties } from 'react';
import { useNode, UserComponent } from '@craftjs/core'; import { useNode, UserComponent } from '@craftjs/core';
import { cssPropsToString } from '../../utils/style-helpers'; import { cssPropsToString } from '../../utils/style-helpers';
import { escapeHtml, escapeAttr, safeUrl } from '../../utils/escape'; import { escapeHtml, escapeAttr, safeUrl, safeImageUrl } from '../../utils/escape';
interface FeatureItem { interface FeatureItem {
title: string; title: string;
@@ -116,7 +116,7 @@ FeaturesGrid.craft = {
const idAttr = props.anchorId ? ` id="${escapeAttr(props.anchorId)}"` : ''; const idAttr = props.anchorId ? ` id="${escapeAttr(props.anchorId)}"` : '';
const cards = (props.features || defaultFeatures).map((feat) => { const cards = (props.features || defaultFeatures).map((feat) => {
const media = feat.image const media = feat.image
? `<img src="${escapeAttr(safeUrl(feat.image))}" alt="${escapeAttr(feat.imageAlt || feat.title || '')}" style="max-width:100%;height:auto;margin-bottom:16px;border-radius:8px">` ? `<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>`; : `<div style="font-size:36px;margin-bottom:16px">${escapeHtml(feat.icon)}</div>`;
const button = feat.buttonText 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>` ? `\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>`
@@ -93,3 +93,59 @@ describe('Gallery.toHtml deterministic + unique scope ids (thread node id, no Ma
expect(html1).toBe(html2); 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\(\)/);
});
});
+33 -5
View File
@@ -1,7 +1,7 @@
import React, { CSSProperties } from 'react'; import React, { CSSProperties } from 'react';
import { useNode, UserComponent } from '@craftjs/core'; import { useNode, UserComponent } from '@craftjs/core';
import { cssPropsToString } from '../../utils/style-helpers'; import { cssPropsToString } from '../../utils/style-helpers';
import { escapeHtml, escapeAttr, safeUrl, scopeId, cssValue } from '../../utils/escape'; import { escapeHtml, escapeAttr, safeImageUrl, scopeId, cssValue } from '../../utils/escape';
interface GalleryImage { interface GalleryImage {
src: string; src: string;
@@ -154,10 +154,10 @@ Gallery.craft = {
// inline onclick with an interpolated src -- a single delegated click // inline onclick with an interpolated src -- a single delegated click
// listener below reads it, so a src containing a quote can't break out // listener below reads it, so a src containing a quote can't break out
// of a per-item event-handler string. // of a per-item event-handler string.
const lbAttr = lightbox ? ` data-lb-src="${escapeAttr(safeUrl(img.src || ''))}" role="button" tabindex="0"` : ''; 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'; const itemStyle = lightbox ? 'cursor:pointer;position:relative;overflow:hidden;border-radius:8px' : 'position:relative;overflow:hidden;border-radius:8px';
return `<div${lbAttr} style="${itemStyle}"> return `<div${lbAttr} style="${itemStyle}">
<img src="${escapeAttr(safeUrl(img.src || ''))}" alt="${escapeAttr(img.alt)}" style="width:100%;height:200px;object-fit:cover;display:block;border-radius:8px;background-color:#f1f5f9" /> <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} ${caption}
</div>`; </div>`;
}).join('\n '); }).join('\n ');
@@ -166,16 +166,37 @@ Gallery.craft = {
let gridIdAttr = ''; let gridIdAttr = '';
if (lightbox) { if (lightbox) {
gridIdAttr = ` id="${galleryId}_grid"`; 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 = ` 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"> <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">&times;</button>
<img id="${galleryId}_img" src="" alt="" style="max-width:90%;max-height:90%;object-fit:contain;border-radius:8px" /> <img id="${galleryId}_img" src="" alt="" style="max-width:90%;max-height:90%;object-fit:contain;border-radius:8px" />
</div> </div>
<script> <script>
function ${galleryId}_close(){document.getElementById('${galleryId}_overlay').style.display='none';} 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){ function ${galleryId}_open(src){
${galleryId}_lastFocus = document.activeElement;
var o = document.getElementById('${galleryId}_overlay'); var o = document.getElementById('${galleryId}_overlay');
document.getElementById('${galleryId}_img').src = src; document.getElementById('${galleryId}_img').src = src;
o.style.display = 'flex'; o.style.display = 'flex';
var c = document.getElementById('${galleryId}_closebtn');
if(c) c.focus();
} }
document.getElementById('${galleryId}_grid').addEventListener('click', function(e){ document.getElementById('${galleryId}_grid').addEventListener('click', function(e){
var t = e.target.closest('[data-lb-src]'); var t = e.target.closest('[data-lb-src]');
@@ -190,7 +211,14 @@ document.getElementById('${galleryId}_grid').addEventListener('keydown', functio
${galleryId}_open(t.getAttribute('data-lb-src')); ${galleryId}_open(t.getAttribute('data-lb-src'));
}); });
document.addEventListener('keydown', function(e){ document.addEventListener('keydown', function(e){
if(e.key==='Escape'){ ${galleryId}_close(); } 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>`; </script>`;
} }
+31 -1
View File
@@ -1,5 +1,5 @@
import React, { useMemo, useRef, useEffect } from 'react'; import React, { useMemo, useRef, useEffect } from 'react';
import { Frame, Element } from '@craftjs/core'; import { Frame, Element, useEditor } from '@craftjs/core';
import { Container } from '../components/layout/Container'; import { Container } from '../components/layout/Container';
import { usePages } from '../state/PageContext'; import { usePages } from '../state/PageContext';
import { DeviceMode } from '../types'; import { DeviceMode } from '../types';
@@ -93,6 +93,33 @@ const ZonePreview: React.FC<{ craftState: string | null; zone: 'header' | 'foote
); );
}; };
/**
* 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,
};
});
if (!isEmpty || isDragging) return null;
return (
<div className="empty-canvas-hint">
<i className="fa fa-cubes" aria-hidden />
<span>Drag blocks from the left panel, or pick a Template to start.</span>
</div>
);
};
export const Canvas: React.FC<CanvasProps> = ({ device }) => { export const Canvas: React.FC<CanvasProps> = ({ device }) => {
const width = DEVICE_WIDTHS[device]; const width = DEVICE_WIDTHS[device];
const { isEditingHeader, isEditingFooter, headerPage, footerPage } = usePages(); const { isEditingHeader, isEditingFooter, headerPage, footerPage } = usePages();
@@ -140,6 +167,7 @@ export const Canvas: React.FC<CanvasProps> = ({ device }) => {
<ZonePreview craftState={headerPage.craftState} zone="header" /> <ZonePreview craftState={headerPage.craftState} zone="header" />
)} )}
<div style={{ position: 'relative' }}>
<Frame> <Frame>
<Element <Element
is={Container} is={Container}
@@ -148,6 +176,8 @@ export const Canvas: React.FC<CanvasProps> = ({ device }) => {
style={frameStyle} style={frameStyle}
/> />
</Frame> </Frame>
{isEditingRegularPage && <EmptyCanvasHint />}
</div>
{isEditingRegularPage && ( {isEditingRegularPage && (
<ZonePreview craftState={footerPage.craftState} zone="footer" /> <ZonePreview craftState={footerPage.craftState} zone="footer" />
+67
View File
@@ -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();
});
});
+108
View File
@@ -0,0 +1,108 @@
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;
dom: HTMLElement | null;
displayName: string;
parent: string | null;
};
const selectNodeSpy = vi.fn();
vi.mock('@craftjs/core', () => ({
useEditor: () => ({ actions: { selectNode: selectNodeSpy } }),
useNode: (collect?: (node: any) => any) => {
const node = {
events: { selected: mockNode.selected },
dom: mockNode.dom,
data: { custom: {}, displayName: mockNode.displayName, parent: mockNode.parent },
};
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, dom: nodeDom, displayName: 'Heading', parent: 'ROOT' };
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();
});
});
+76
View File
@@ -0,0 +1,76 @@
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.
*/
export const RenderNode: React.FC<RenderNodeProps> = ({ render }) => {
const { actions } = useEditor();
const { id, isSelected, dom, name, parent } = useNode((node) => ({
isSelected: node.events.selected,
dom: node.dom,
name: (node.data.props?.aiName as string) || node.data.displayName,
parent: node.data.parent,
}));
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]);
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
)}
</>
);
};
+31
View File
@@ -1,6 +1,7 @@
import { describe, test, expect } from 'vitest'; import { describe, test, expect } from 'vitest';
import { buildSavePayload } from './useWhpApi'; import { buildSavePayload } from './useWhpApi';
import { PageData } from '../types'; import { PageData } from '../types';
import { DEFAULT_SITE_DESIGN } from '../state/SiteDesignContext';
const pageA: PageData = { id: 'home', name: 'Home', slug: 'index', craftState: 'STORED_HOME' }; const pageA: PageData = { id: 'home', name: 'Home', slug: 'index', craftState: 'STORED_HOME' };
const pageB: PageData = { id: 'page_2', name: 'About', slug: 'about', craftState: 'STORED_ABOUT' }; const pageB: PageData = { id: 'page_2', name: 'About', slug: 'about', craftState: 'STORED_ABOUT' };
@@ -19,6 +20,8 @@ describe('buildSavePayload', () => {
activePageId: '__header__', activePageId: '__header__',
isEditingHeader: true, isEditingHeader: true,
isEditingFooter: false, isEditingFooter: false,
headCode: DEFAULT_SITE_DESIGN.headCode,
design: DEFAULT_SITE_DESIGN,
}); });
// The fresh live canvas must be reflected in header_craft_state, not the stale stored one. // The fresh live canvas must be reflected in header_craft_state, not the stale stored one.
@@ -52,6 +55,8 @@ describe('buildSavePayload', () => {
activePageId: '__footer__', activePageId: '__footer__',
isEditingHeader: false, isEditingHeader: false,
isEditingFooter: true, isEditingFooter: true,
headCode: DEFAULT_SITE_DESIGN.headCode,
design: DEFAULT_SITE_DESIGN,
}); });
expect(payload.footer_craft_state).toBe('LIVE_FOOTER'); expect(payload.footer_craft_state).toBe('LIVE_FOOTER');
@@ -75,6 +80,8 @@ describe('buildSavePayload', () => {
activePageId: 'home', activePageId: 'home',
isEditingHeader: false, isEditingHeader: false,
isEditingFooter: false, isEditingFooter: false,
headCode: DEFAULT_SITE_DESIGN.headCode,
design: DEFAULT_SITE_DESIGN,
}); });
// Live canvas goes to the active page and top-level slots. // Live canvas goes to the active page and top-level slots.
@@ -105,6 +112,8 @@ describe('buildSavePayload', () => {
activePageId: 'home', activePageId: 'home',
isEditingHeader: false, isEditingHeader: false,
isEditingFooter: false, isEditingFooter: false,
headCode: DEFAULT_SITE_DESIGN.headCode,
design: DEFAULT_SITE_DESIGN,
}); });
// The live edit must land in pages_craft_state[0] (index.html slot), // The live edit must land in pages_craft_state[0] (index.html slot),
@@ -116,4 +125,26 @@ describe('buildSavePayload', () => {
// The other page is untouched. // The other page is untouched.
expect(payload.pages_craft_state.find((p) => p.id === 'p2')?.craftState).toBe('STORED_ABOUT_2'); expect(payload.pages_craft_state.find((p) => p.id === 'p2')?.craftState).toBe('STORED_ABOUT_2');
}); });
test('head code + design tokens are included in the save payload', () => {
const design = { ...DEFAULT_SITE_DESIGN, headCode: '<meta name="x">' };
const payload = buildSavePayload({
siteId: 1,
siteName: 'Test Site',
liveCraftState: 'LIVE_PAGE_HOME',
pages: [pageA, pageB],
headerPage,
footerPage,
activePageId: 'home',
isEditingHeader: false,
isEditingFooter: false,
headCode: '<meta name="x">',
design,
});
expect(payload.head_code).toBe('<meta name="x">');
expect(payload.design).toEqual(design);
expect(payload.design.headCode).toBe('<meta name="x">');
});
}); });
+25 -2
View File
@@ -2,6 +2,7 @@ import { useCallback } from 'react';
import { useEditor } from '@craftjs/core'; import { useEditor } from '@craftjs/core';
import { useEditorConfig } from '../state/EditorConfigContext'; import { useEditorConfig } from '../state/EditorConfigContext';
import { usePages } from '../state/PageContext'; import { usePages } from '../state/PageContext';
import { useSiteDesign, SiteDesign } from '../state/SiteDesignContext';
import { exportBodyHtml } from '../utils/html-export'; import { exportBodyHtml } from '../utils/html-export';
import { PageData } from '../types'; import { PageData } from '../types';
@@ -18,6 +19,10 @@ export interface BuildSavePayloadInput {
isEditingHeader: boolean; isEditingHeader: boolean;
/** True when the live canvas is showing the footer zone (activePageId === '__footer__'). */ /** True when the live canvas is showing the footer zone (activePageId === '__footer__'). */
isEditingFooter: boolean; isEditingFooter: boolean;
/** Site-wide custom `<head>` code (also present on `design.headCode`). */
headCode: string;
/** Full site design tokens object -- lets load() restore colors/fonts/headCode. */
design: SiteDesign;
} }
/** /**
@@ -45,6 +50,8 @@ export function buildSavePayload(input: BuildSavePayloadInput) {
activePageId, activePageId,
isEditingHeader, isEditingHeader,
isEditingFooter, isEditingFooter,
headCode,
design,
} = input; } = input;
const isPageActive = !isEditingHeader && !isEditingFooter; const isPageActive = !isEditingHeader && !isEditingFooter;
@@ -173,6 +180,8 @@ export function buildSavePayload(input: BuildSavePayloadInput) {
header_craft_state: headerCraftState, header_craft_state: headerCraftState,
footer_craft_state: footerCraftState, footer_craft_state: footerCraftState,
pages_craft_state: pagesGrapesjs, pages_craft_state: pagesGrapesjs,
head_code: headCode,
design,
}; };
} }
@@ -191,6 +200,7 @@ export function useWhpApi() {
setPagesCraftState, setPagesCraftState,
setActivePageIdDirect, setActivePageIdDirect,
} = usePages(); } = usePages();
const { design, updateDesign } = useSiteDesign();
const save = useCallback(async () => { const save = useCallback(async () => {
if (!isWHP || !whpConfig) return null; if (!isWHP || !whpConfig) return null;
@@ -210,6 +220,8 @@ export function useWhpApi() {
activePageId, activePageId,
isEditingHeader, isEditingHeader,
isEditingFooter, isEditingFooter,
headCode: design.headCode,
design,
}); });
const resp = await fetch(`${whpConfig.apiUrl}?action=save`, { const resp = await fetch(`${whpConfig.apiUrl}?action=save`, {
@@ -221,7 +233,7 @@ export function useWhpApi() {
body: JSON.stringify(payload), body: JSON.stringify(payload),
}); });
return resp.json(); return resp.json();
}, [isWHP, whpConfig, query, pages, activePageId, headerPage, footerPage, isEditingHeader, isEditingFooter]); }, [isWHP, whpConfig, query, pages, activePageId, headerPage, footerPage, isEditingHeader, isEditingFooter, design]);
const publish = useCallback(async () => { const publish = useCallback(async () => {
if (!isWHP || !whpConfig) return null; if (!isWHP || !whpConfig) return null;
@@ -255,6 +267,17 @@ export function useWhpApi() {
if (data.success && data.project) { if (data.success && data.project) {
const proj = data.project; const proj = data.project;
// Restore site design tokens (colors/fonts/headCode) so the editor
// reflects what was last saved. Prefer the full `design` object when
// present; fall back to just `head_code` for older project.json files
// saved before this field existed (backward-compatible: defaults for
// everything else).
if (proj.design && typeof proj.design === 'object') {
updateDesign(proj.design);
} else if (typeof proj.head_code === 'string') {
updateDesign({ headCode: proj.head_code });
}
// Restore header craft state // Restore header craft state
if (proj.header_craft_state) { if (proj.header_craft_state) {
setHeaderCraftState(typeof proj.header_craft_state === 'string' setHeaderCraftState(typeof proj.header_craft_state === 'string'
@@ -300,7 +323,7 @@ export function useWhpApi() {
} }
} }
return data; return data;
}, [isWHP, whpConfig, actions, setHeaderCraftState, setFooterCraftState, setPagesCraftState, setActivePageIdDirect, isEditingHeader, isEditingFooter]); }, [isWHP, whpConfig, actions, setHeaderCraftState, setFooterCraftState, setPagesCraftState, setActivePageIdDirect, isEditingHeader, isEditingFooter, updateDesign]);
const uploadAsset = useCallback( const uploadAsset = useCallback(
async (file: File) => { async (file: File) => {
@@ -16,6 +16,8 @@ interface ContextMenuProps {
interface MenuItem { interface MenuItem {
label: string; label: string;
/** Font Awesome icon suffix (e.g. 'magic' for fa-magic), rendered before the label. */
icon?: string;
shortcut?: string; shortcut?: string;
action: () => void; action: () => void;
danger?: boolean; danger?: boolean;
@@ -189,7 +191,8 @@ export const ContextMenu: React.FC<ContextMenuProps> = ({
const items: MenuItem[] = [ const items: MenuItem[] = [
{ {
label: '✨ Ask Sitesmith', label: 'Ask Sitesmith',
icon: 'magic',
action: askSitesmith, action: askSitesmith,
disabled: isRoot, disabled: isRoot,
dividerAfter: true, dividerAfter: true,
@@ -291,7 +294,10 @@ export const ContextMenu: React.FC<ContextMenuProps> = ({
(e.target as HTMLElement).style.background = 'transparent'; (e.target as HTMLElement).style.background = 'transparent';
}} }}
> >
<span>{item.label}</span> <span>
{item.icon && <i className={`fa fa-${item.icon}`} style={{ marginRight: 6, width: 12 }} />}
{item.label}
</span>
{item.shortcut && ( {item.shortcut && (
<span <span
style={{ style={{
+21 -19
View File
@@ -107,23 +107,39 @@ export const AssetsPanel: React.FC = () => {
{loading ? 'Uploading...' : 'Upload File'} {loading ? 'Uploading...' : 'Upload File'}
</button> </button>
{/* Drop zone */} {/* Drop zone -- a single element that doubles as the empty state.
Previously this was a small always-visible dropzone PLUS a
separate italic "No assets uploaded yet" line stacked underneath
it when empty; merged into one tall dropzone (icon + copy,
click-or-drag) so the empty state isn't two redundant messages.
Once assets exist it collapses back to a slim persistent drop
target above the grid. */}
<div <div
onDrop={handleDrop} onDrop={handleDrop}
onDragOver={handleDragOver} onDragOver={handleDragOver}
onDragLeave={handleDragLeave} onDragLeave={handleDragLeave}
{...(assets.length === 0 ? clickableProps(() => fileInputRef.current?.click()) : {})}
style={{ style={{
padding: 20, display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
gap: 8,
padding: assets.length === 0 ? '36px 20px' : 16,
border: `2px dashed ${isDragOver ? 'var(--color-accent)' : 'var(--color-border)'}`, border: `2px dashed ${isDragOver ? 'var(--color-accent)' : 'var(--color-border)'}`,
borderRadius: 'var(--radius-md)', borderRadius: 'var(--radius-md)',
background: isDragOver ? 'var(--color-accent-subtle)' : 'transparent', background: isDragOver ? 'var(--color-accent-subtle)' : 'transparent',
textAlign: 'center', textAlign: 'center',
color: isDragOver ? 'var(--color-accent)' : 'var(--color-text-dim)', color: isDragOver ? 'var(--color-accent)' : 'var(--color-text-dim)',
fontSize: 11, fontSize: 11,
cursor: assets.length === 0 ? 'pointer' : 'default',
transition: 'all var(--transition-fast)', transition: 'all var(--transition-fast)',
}} }}
> >
Drop files here to upload {assets.length === 0 && (
<i className="fa fa-cloud-upload" aria-hidden style={{ fontSize: 28, opacity: 0.5 }} />
)}
{assets.length === 0 ? 'Drag images here or click to upload' : 'Drop files here to upload'}
</div> </div>
{/* Error message */} {/* Error message */}
@@ -143,20 +159,6 @@ export const AssetsPanel: React.FC = () => {
)} )}
{/* Asset grid */} {/* Asset grid */}
{assets.length === 0 && !loading && (
<div
style={{
textAlign: 'center',
padding: 20,
color: 'var(--color-text-dim)',
fontSize: 12,
fontStyle: 'italic',
}}
>
No assets uploaded yet
</div>
)}
<div <div
style={{ style={{
display: 'grid', display: 'grid',
@@ -296,7 +298,7 @@ export const AssetsPanel: React.FC = () => {
cursor: 'pointer', cursor: 'pointer',
}} }}
> >
&#10005; <i className="fa fa-times" aria-hidden />
</button> </button>
</div> </div>
) : ( ) : (
@@ -328,7 +330,7 @@ export const AssetsPanel: React.FC = () => {
onMouseEnter={(e) => { (e.target as HTMLElement).style.opacity = '1'; }} onMouseEnter={(e) => { (e.target as HTMLElement).style.opacity = '1'; }}
onMouseLeave={(e) => { (e.target as HTMLElement).style.opacity = '0.7'; }} onMouseLeave={(e) => { (e.target as HTMLElement).style.opacity = '0.7'; }}
> >
&#10005; <i className="fa fa-times" aria-hidden />
</button> </button>
)} )}
</div> </div>
+1 -1
View File
@@ -123,7 +123,7 @@ const categories: CategoryDef[] = [
label: 'Media', label: 'Media',
blocks: [ blocks: [
{ id: 'image', label: 'Image', icon: 'fa-image', { id: 'image', label: 'Image', icon: 'fa-image',
component: <ImageBlock src="" alt="Image" style={{ maxWidth: '100%', height: 'auto', display: 'block', borderRadius: '8px' }} /> }, component: <ImageBlock alt="Image" style={{ maxWidth: '100%', height: 'auto', display: 'block', borderRadius: '8px' }} /> },
{ id: 'video', label: 'Video', icon: 'fa-play-circle', { id: 'video', label: 'Video', icon: 'fa-play-circle',
component: <VideoBlock videoUrl="" isBackground={false} /> }, component: <VideoBlock videoUrl="" isBackground={false} /> },
{ id: 'map-embed', label: 'Map', icon: 'fa-map-marker', { id: 'map-embed', label: 'Map', icon: 'fa-map-marker',
+1 -1
View File
@@ -394,7 +394,7 @@ export const PagesPanel: React.FC = () => {
cursor: 'pointer', cursor: 'pointer',
}} }}
> >
&#10005; <i className="fa fa-trash" aria-hidden="true" />
</button> </button>
)} )}
</div> </div>
@@ -11,6 +11,7 @@ import {
useNodeProp, useNodeProp,
} from './shared'; } from './shared';
import { AssetPicker } from '../../../ui/AssetPicker'; import { AssetPicker } from '../../../ui/AssetPicker';
import { PLACEHOLDER_SRC } from '../../../components/media/ImageBlock';
/* ---------- IMAGE (with upload/browse/drop) ---------- */ /* ---------- IMAGE (with upload/browse/drop) ---------- */
export const ImageStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeProps }) => { export const ImageStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeProps }) => {
@@ -33,7 +34,7 @@ export const ImageStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodePro
<SectionLabel>Image Source</SectionLabel> <SectionLabel>Image Source</SectionLabel>
<AssetPicker <AssetPicker
value={nodeProps.src || ''} value={nodeProps.src || ''}
onChange={(url) => actions.setProp(selectedId, (props: any) => { props.src = url; })} onChange={(url) => actions.setProp(selectedId, (props: any) => { props.src = url || PLACEHOLDER_SRC; })}
variant="full" variant="full"
/> />
</div> </div>
@@ -0,0 +1,38 @@
import { describe, it, expect } from 'vitest';
import { defaultPresetGridColumns } from './shared';
import { RADIUS_PRESETS, SPACING_PRESETS, IMAGE_RADIUS_PRESETS, FONT_WEIGHTS, TEXT_SIZES, FONT_FAMILIES } from '../../../constants/presets';
/*
* Regression: 5-item preset sets (RADIUS_PRESETS, SPACING_PRESETS,
* IMAGE_RADIUS_PRESETS, FONT_WEIGHTS, and NavStylePanel's ad-hoc
* GAP_PRESETS) left a lone orphan button on its own row under the old
* fixed 4-column .preset-grid. PresetButtonGrid now derives a column
* count from the preset length instead.
*/
describe('defaultPresetGridColumns', () => {
it('gives 5-item sets their own single row (no orphan)', () => {
expect(defaultPresetGridColumns(RADIUS_PRESETS.length)).toBe(5);
expect(defaultPresetGridColumns(SPACING_PRESETS.length)).toBe(5);
expect(defaultPresetGridColumns(IMAGE_RADIUS_PRESETS.length)).toBe(5);
expect(defaultPresetGridColumns(FONT_WEIGHTS.length)).toBe(5);
expect(defaultPresetGridColumns(5)).toBe(5); // NavStylePanel's GAP_PRESETS
});
it('splits 6-item sets into two even rows of 3 (was 4+2 uneven)', () => {
expect(defaultPresetGridColumns(TEXT_SIZES.length)).toBe(3);
});
it('keeps the classic 4-column grid for sets that already divide evenly', () => {
expect(defaultPresetGridColumns(FONT_FAMILIES.length)).toBe(4); // 8 items
expect(defaultPresetGridColumns(4)).toBe(4);
expect(defaultPresetGridColumns(3)).toBe(4);
});
it('never leaves a single orphan on the final row for any count 1-12', () => {
for (let n = 1; n <= 12; n++) {
const cols = defaultPresetGridColumns(n);
const isLoneOrphan = n > cols && n % cols === 1;
expect(isLoneOrphan).toBe(false);
}
});
});
+25 -3
View File
@@ -84,9 +84,30 @@ interface PresetButtonGridProps {
presets: { label: string; value: string }[]; presets: { label: string; value: string }[];
activeValue: string | undefined; activeValue: string | undefined;
onSelect: (value: string) => void; onSelect: (value: string) => void;
/** Explicit column count. When omitted, a column count is derived from
* `presets.length` (see `defaultPresetGridColumns`) so odd-sized preset
* sets (5, 6, ...) don't leave a lone orphan button dangling on its own
* row under the fixed 4-column grid. */
columns?: number;
} }
export const PresetButtonGrid: React.FC<PresetButtonGridProps> = ({ presets, activeValue, onSelect }) => (
<div className="preset-grid"> /** Picks a column count that avoids a single orphan on the last row.
* 4-or-fewer presets keep the classic single row of 4. 5 gets its own
* row (5 cols). 6 splits into two even rows of 3. Anything else falls
* back to a 4- or 3-column grid depending on which divides evenly. */
export function defaultPresetGridColumns(count: number): number {
if (count <= 4) return 4;
if (count === 5) return 5;
if (count === 6) return 3;
if (count % 4 === 0) return 4;
if (count % 3 === 0) return 3;
return 4;
}
export const PresetButtonGrid: React.FC<PresetButtonGridProps> = ({ presets, activeValue, onSelect, columns }) => {
const cols = columns ?? defaultPresetGridColumns(presets.length);
return (
<div className="preset-grid" style={{ gridTemplateColumns: `repeat(${cols}, 1fr)` }}>
{presets.map((p) => ( {presets.map((p) => (
<button <button
key={p.value} key={p.value}
@@ -97,7 +118,8 @@ export const PresetButtonGrid: React.FC<PresetButtonGridProps> = ({ presets, act
</button> </button>
))} ))}
</div> </div>
); );
};
interface GradientSwatchGridProps { interface GradientSwatchGridProps {
activeValue: string | undefined; activeValue: string | undefined;
@@ -21,8 +21,8 @@ export const SitesmithButton: React.FC<Props> = ({ onClick }) => {
color: '#fff', border: 'none', padding: '6px 12px', borderRadius: 6, cursor: 'pointer', fontWeight: 500, color: '#fff', border: 'none', padding: '6px 12px', borderRadius: 6, cursor: 'pointer', fontWeight: 500,
}} }}
> >
✨ Sitesmith <i className="fa fa-magic" aria-hidden style={{ marginRight: 6 }} /> Sitesmith
{locked && <span aria-hidden style={{ marginLeft: 6, fontSize: 12 }}>🔒</span>} {locked && <i className="fa fa-lock" aria-hidden style={{ marginLeft: 6, fontSize: 12 }} />}
{capped && !locked && <span aria-hidden style={{ marginLeft: 6, fontSize: 11, opacity: 0.85 }}>(cap)</span>} {capped && !locked && <span aria-hidden style={{ marginLeft: 6, fontSize: 11, opacity: 0.85 }}>(cap)</span>}
</button> </button>
); );
@@ -103,7 +103,10 @@ export const SitesmithModal: React.FC<Props> = ({ onClose, target }) => {
> >
<div style={panel}> <div style={panel}>
<div style={header}> <div style={header}>
<div style={{ fontWeight: 600, color: '#fff' }}>✨ Sitesmith</div> <div style={{ fontWeight: 600, color: '#fff' }}>
<i className="fa fa-magic" aria-hidden style={{ marginRight: 6 }} />
Sitesmith
</div>
{summary && summary.enabled && ( {summary && summary.enabled && (
<div style={{ fontSize: 12, color: '#a1a1aa', marginLeft: 16 }}> <div style={{ fontSize: 12, color: '#a1a1aa', marginLeft: 16 }}>
{summary.monthly_used} / {summary.monthly_cap} this month {summary.monthly_used} / {summary.monthly_cap} this month
@@ -136,7 +139,7 @@ export const SitesmithModal: React.FC<Props> = ({ onClose, target }) => {
</button> </button>
) )
)} )}
<button onClick={onClose} aria-label="Close" style={closeBtn}>✕</button> <button onClick={onClose} aria-label="Close" style={closeBtn}><i className="fa fa-times" aria-hidden /></button>
</div> </div>
<div style={body}> <div style={body}>
<UpgradeBanner summary={summary} /> <UpgradeBanner summary={summary} />
+1 -1
View File
@@ -248,7 +248,7 @@ export const TemplateModal: React.FC<TemplateModalProps> = ({ open, onClose }) =
</p> </p>
</div> </div>
<button onClick={onClose} style={closeButtonStyle} title="Close"> <button onClick={onClose} style={closeButtonStyle} title="Close">
&#10005; <i className="fa fa-times" aria-hidden />
</button> </button>
</div> </div>
+22 -6
View File
@@ -2,7 +2,7 @@ import { describe, test, expect, vi, beforeEach, afterEach } from 'vitest';
import React from 'react'; import React from 'react';
import { createRoot, Root } from 'react-dom/client'; import { createRoot, Root } from 'react-dom/client';
import { act } from 'react-dom/test-utils'; import { act } from 'react-dom/test-utils';
import { PageProvider, usePages, uniqueSlug } from './PageContext'; import { PageProvider, usePages, uniqueSlug, nextPageId } from './PageContext';
/* PageContext only needs `useEditor` from @craftjs/core (for query.serialize / /* PageContext only needs `useEditor` from @craftjs/core (for query.serialize /
actions.deserialize during page switches) — mock just that so PageProvider actions.deserialize during page switches) — mock just that so PageProvider
@@ -15,11 +15,10 @@ vi.mock('@craftjs/core', () => ({
}), }),
})); }));
/* addPage mints ids from `Date.now()`. Two adds inside the same test can land /* addPage mints ids via nextPageId() (timestamp + monotonic counter, M-3),
in the same millisecond and collide on id, which is an existing, unrelated so same-millisecond calls no longer collide on id. Date.now() is still
bug (id collision, not slug collision) — out of scope here but it makes pinned/advanced here for determinism across the slug-dedupe assertions
these tests flaky since a colliding id defeats the "other pages" slug below, independent of wall-clock timing. */
lookup. Force distinct ids so the slug-dedupe assertions below are stable. */
let dateNowSpy: ReturnType<typeof vi.spyOn>; let dateNowSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => { beforeEach(() => {
let counter = 1_700_000_000_000; let counter = 1_700_000_000_000;
@@ -48,6 +47,23 @@ function unmount() {
container.remove(); container.remove();
} }
describe('nextPageId (M-3: no same-millisecond id collision)', () => {
test('two calls yield distinct ids even when Date.now() is pinned to a constant', () => {
const spy = vi.spyOn(Date, 'now').mockReturnValue(1_700_000_000_000);
try {
const id1 = nextPageId();
const id2 = nextPageId();
expect(id1).not.toBe(id2);
} finally {
spy.mockRestore();
}
});
test('ids are prefixed with "page_"', () => {
expect(nextPageId()).toMatch(/^page_/);
});
});
describe('uniqueSlug', () => { describe('uniqueSlug', () => {
test('returns base unchanged when no collision', () => { test('returns base unchanged when no collision', () => {
expect(uniqueSlug('about', ['index', 'contact'])).toBe('about'); expect(uniqueSlug('about', ['index', 'contact'])).toBe('about');
+16 -2
View File
@@ -43,6 +43,20 @@ interface PageContextValue {
const HEADER_ID = '__header__'; const HEADER_ID = '__header__';
const FOOTER_ID = '__footer__'; const FOOTER_ID = '__footer__';
// M-3: `page_${Date.now()}` alone collides when two pages are minted inside
// the same millisecond (addPage called twice in quick succession, or two AI
// replaceAllPages entries) -- then rename/delete/save operate on both pages
// at once since they share an id. A module-scoped monotonic counter,
// combined with the timestamp, guarantees uniqueness regardless of how many
// ids are minted within the same millisecond. This is state/id-minting code
// (not toHtml/export), so Date.now() here is fine -- see task-minors-brief.md.
let pageIdCounter = 0;
/** Mints a unique page id: timestamp (base36) + a monotonic per-process counter (base36). */
export function nextPageId(): string {
return 'page_' + Date.now().toString(36) + '_' + (++pageIdCounter).toString(36);
}
const EMPTY_CANVAS = const EMPTY_CANVAS =
'{"ROOT":{"type":{"resolvedName":"Container"},"isCanvas":true,"props":{"style":{"minHeight":"100vh","backgroundColor":"#ffffff"},"tag":"div"},"displayName":"Container","custom":{},"hidden":false,"nodes":[],"linkedNodes":{}}}'; '{"ROOT":{"type":{"resolvedName":"Container"},"isCanvas":true,"props":{"style":{"minHeight":"100vh","backgroundColor":"#ffffff"},"tag":"div"},"displayName":"Container","custom":{},"hidden":false,"nodes":[],"linkedNodes":{}}}';
@@ -338,7 +352,7 @@ export const PageProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
const addPage = useCallback( const addPage = useCallback(
(name: string, slug: string) => { (name: string, slug: string) => {
const requestedSlug = slug || slugify(name); const requestedSlug = slug || slugify(name);
const id = `page_${Date.now()}`; const id = nextPageId();
// Save current page first // Save current page first
saveCurrentState(); saveCurrentState();
@@ -452,7 +466,7 @@ export const PageProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
const slug = i === 0 ? 'index' : uniqueSlug(slugify(p.name), seenSlugs); const slug = i === 0 ? 'index' : uniqueSlug(slugify(p.name), seenSlugs);
seenSlugs.push(slug); seenSlugs.push(slug);
return { return {
id: i === 0 ? 'home' : `page_${Date.now()}_${i}`, id: i === 0 ? 'home' : nextPageId(),
name: p.name, name: p.name,
slug, slug,
craftState: treeToCraftState(p.tree), craftState: treeToCraftState(p.tree),
+37 -6
View File
@@ -16,8 +16,8 @@
--color-border: #2d2d3a; --color-border: #2d2d3a;
--color-border-light: #3f3f46; --color-border-light: #3f3f46;
--color-text: #e4e4e7; --color-text: #e4e4e7;
--color-text-muted: #71717a; --color-text-muted: #8b8b96;
--color-text-dim: #52525b; --color-text-dim: #6e6e78;
--color-accent: #3b82f6; --color-accent: #3b82f6;
--color-accent-hover: #2563eb; --color-accent-hover: #2563eb;
--color-accent-subtle: rgba(59, 130, 246, 0.12); --color-accent-subtle: rgba(59, 130, 246, 0.12);
@@ -704,7 +704,7 @@ body {
} }
.block-item-icon { .block-item-icon {
font-size: 18px; font-size: 20px;
color: var(--color-text-muted); color: var(--color-text-muted);
transition: color var(--transition-fast); transition: color var(--transition-fast);
} }
@@ -714,7 +714,7 @@ body {
} }
.block-item-label { .block-item-label {
font-size: 10px; font-size: 11px;
font-weight: 500; font-weight: 500;
color: var(--color-text-muted); color: var(--color-text-muted);
text-align: center; text-align: center;
@@ -1074,12 +1074,16 @@ body {
outline-offset: -1px; outline-offset: -1px;
} }
/* Component indicator badge */ /* Component indicator badge (floats over the selected node via a portal --
position/top/left are set inline per-node, see RenderNode.tsx) */
.component-indicator { .component-indicator {
position: absolute; position: absolute;
top: -22px; top: -22px;
left: 0; left: 0;
padding: 2px 8px; display: flex;
align-items: center;
gap: 6px;
padding: 2px 6px 2px 8px;
font-size: 10px; font-size: 10px;
font-weight: 600; font-weight: 600;
color: #ffffff; color: #ffffff;
@@ -1090,6 +1094,28 @@ body {
z-index: 10; z-index: 10;
} }
.component-indicator-parent-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 14px;
height: 14px;
padding: 0;
border: none;
border-radius: 2px;
background: rgba(255, 255, 255, 0.2);
color: #ffffff;
font-size: 8px;
line-height: 1;
cursor: pointer;
pointer-events: auto;
transition: background var(--transition-fast);
}
.component-indicator-parent-btn:hover {
background: rgba(255, 255, 255, 0.4);
}
/* -------------------------------------------------------------------------- /* --------------------------------------------------------------------------
Scrollbar Styling Scrollbar Styling
-------------------------------------------------------------------------- */ -------------------------------------------------------------------------- */
@@ -1225,6 +1251,8 @@ body {
Empty Canvas State Empty Canvas State
-------------------------------------------------------------------------- */ -------------------------------------------------------------------------- */
.empty-canvas-hint { .empty-canvas-hint {
position: absolute;
inset: 0;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
@@ -1234,6 +1262,9 @@ body {
font-size: 13px; font-size: 13px;
text-align: center; text-align: center;
gap: 12px; gap: 12px;
/* Overlays the drop area without intercepting drags/clicks meant for the
underlying (empty) canvas root -- see Canvas.tsx. */
pointer-events: none;
} }
.empty-canvas-hint i { .empty-canvas-hint i {
+104 -3
View File
@@ -1,5 +1,5 @@
import { describe, test, expect } from 'vitest'; import { describe, test, expect } from 'vitest';
import { escapeHtml, escapeAttr, safeUrl, stableHash, scopeId, sanitizeFormMethod, sanitizeInputType } from './escape'; import { escapeHtml, escapeAttr, safeUrl, safeImageUrl, stableHash, scopeId, sanitizeFormMethod, sanitizeInputType } from './escape';
describe('escapeHtml', () => { describe('escapeHtml', () => {
test('escapes &, <, >, "', () => { test('escapes &, <, >, "', () => {
@@ -71,6 +71,24 @@ describe('safeUrl', () => {
expect(safeUrl(s)).toBe(s); expect(safeUrl(s)).toBe(s);
}); });
test('M-5: blocks data:image/svg+xml (can execute script when navigated to directly)', () => {
expect(safeUrl('data:image/svg+xml,<svg onload=alert(1)>')).toBe('');
});
test('M-5: blocks data:image/svg+xml;base64 variant', () => {
expect(safeUrl('data:image/svg+xml;base64,PHN2ZyBvbmxvYWQ9YWxlcnQoMSk+')).toBe('');
});
test('M-5: blocks obfuscated (whitespace/case) data:image/svg+xml', () => {
expect(safeUrl(' DATA:IMAGE/SVG+XML,<svg onload=alert(1)>')).toBe('');
});
test('M-5: still allows other data:image/* types unchanged', () => {
expect(safeUrl('data:image/jpeg;base64,/9j/4AAQ')).toBe('data:image/jpeg;base64,/9j/4AAQ');
expect(safeUrl('data:image/gif;base64,R0lGOD')).toBe('data:image/gif;base64,R0lGOD');
expect(safeUrl('data:image/webp;base64,UklGR')).toBe('data:image/webp;base64,UklGR');
});
test.each([ test.each([
'https://x.com/a?b=1&c=2', 'https://x.com/a?b=1&c=2',
'http://x', 'http://x',
@@ -94,6 +112,73 @@ describe('safeUrl', () => {
}); });
}); });
describe('safeImageUrl (Bug 2: image-context sink -- data:image/svg+xml is safe as an <img>/CSS url() target)', () => {
test('allows data:image/svg+xml (a raw, non-base64 SVG data URI, as Gallery/ImageBlock placeholders use)', () => {
const s = 'data:image/svg+xml,<svg/>';
expect(safeImageUrl(s)).toBe(s);
});
test('allows data:image/svg+xml;base64 variant', () => {
const s = 'data:image/svg+xml;base64,PHN2Zy8+';
expect(safeImageUrl(s)).toBe(s);
});
test('allows other data:image/* types unchanged', () => {
expect(safeImageUrl('data:image/png;base64,x')).toBe('data:image/png;base64,x');
expect(safeImageUrl('data:image/jpeg;base64,x')).toBe('data:image/jpeg;base64,x');
expect(safeImageUrl('data:image/webp;base64,x')).toBe('data:image/webp;base64,x');
expect(safeImageUrl('data:image/gif;base64,x')).toBe('data:image/gif;base64,x');
});
test('still blocks javascript: scheme', () => {
expect(safeImageUrl('javascript:alert(1)')).toBe('');
});
test('still blocks vbscript: scheme', () => {
expect(safeImageUrl('vbscript:msgbox(1)')).toBe('');
});
test('still blocks data:text/html', () => {
expect(safeImageUrl('data:text/html,x')).toBe('');
});
test('still blocks non-image data: types generally (e.g. data:application/...)', () => {
expect(safeImageUrl('data:application/javascript,alert(1)')).toBe('');
});
test('blocks obfuscated (whitespace/case) javascript: scheme, same as safeUrl', () => {
expect(safeImageUrl(' JavaScript:alert(1)')).toBe('');
expect(safeImageUrl('java\tscript:alert(1)')).toBe('');
expect(safeImageUrl('&#106;avascript:alert(1)')).toBe('');
});
test('allows ordinary http(s)/relative urls unchanged, same as safeUrl', () => {
expect(safeImageUrl('https://example.com/photo.jpg')).toBe('https://example.com/photo.jpg');
expect(safeImageUrl('/assets/photo.jpg')).toBe('/assets/photo.jpg');
expect(safeImageUrl('')).toBe('');
});
test('coerces non-string to empty string', () => {
expect(safeImageUrl(null as any)).toBe('');
expect(safeImageUrl(undefined as any)).toBe('');
});
test('tightened allowlist: blocks a bogus MIME that merely starts with "image" but has no slash (e.g. data:imagehtml/...)', () => {
expect(safeImageUrl('data:imagehtml/svg+xml,x')).toBe('');
});
test('tightened allowlist: still allows legit data:image/* subtypes', () => {
expect(safeImageUrl('data:image/png;base64,x')).toBe('data:image/png;base64,x');
expect(safeImageUrl('data:image/svg+xml,<svg/>')).toBe('data:image/svg+xml,<svg/>');
});
});
describe('safeUrl still blocks data:image/svg+xml (href/iframe/navigation context unchanged by safeImageUrl addition)', () => {
test('safeUrl(data:image/svg+xml,...) is still blocked', () => {
expect(safeUrl('data:image/svg+xml,<svg onload=alert(1)>')).toBe('');
});
});
describe('stableHash', () => { describe('stableHash', () => {
test('same input always produces the same output', () => { test('same input always produces the same output', () => {
expect(stableHash('hello')).toBe(stableHash('hello')); expect(stableHash('hello')).toBe(stableHash('hello'));
@@ -120,8 +205,24 @@ describe('scopeId', () => {
expect(id1).not.toBe(id2); expect(id1).not.toBe(id2);
}); });
test('node id is slugified (non-alphanumeric characters stripped, lowercased)', () => { test('output is a valid CSS ident: prefix_hash', () => {
expect(scopeId('Node ID! 123', 'seed', 'sb')).toBe('sb_nodeid123'); expect(scopeId('Node ID! 123', 'seed', 'sb')).toMatch(/^sb_[a-z0-9]+$/);
});
test('M-4: case-differing node ids do not collapse to the same scope (no case-fold collision)', () => {
expect(scopeId('AbC', 'seed', 'sb')).not.toBe(scopeId('abc', 'seed', 'sb'));
});
test('M-4: punctuation-differing node ids do not collapse to the same scope', () => {
expect(scopeId('a-b', 'seed', 'sb')).not.toBe(scopeId('ab', 'seed', 'sb'));
});
test('M-4: same input always yields the identical scope id', () => {
expect(scopeId('some-node-id', 'seed', 'sb')).toBe(scopeId('some-node-id', 'seed', 'sb'));
});
test('M-4: output always matches a valid CSS ident pattern', () => {
expect(scopeId('Weird!! Node--ID__123', 'seed', 'sb')).toMatch(/^[a-z]+_[a-z0-9]+$/i);
}); });
test('no nodeId (legacy 2-arg call sites) falls back to a deterministic hash of the seed, not Math.random', () => { test('no nodeId (legacy 2-arg call sites) falls back to a deterministic hash of the seed, not Math.random', () => {
+72 -17
View File
@@ -36,7 +36,14 @@ export function escapeAttr(s: string): string {
// Dangerous scheme prefixes, checked against a normalized copy with all // Dangerous scheme prefixes, checked against a normalized copy with all
// colons removed (see safeUrl) so that colon-splicing obfuscation like // colons removed (see safeUrl) so that colon-splicing obfuscation like
// "java:script:alert(1)" can't slip past a literal "javascript:" check. // "java:script:alert(1)" can't slip past a literal "javascript:" check.
const DANGEROUS_SCHEME_PREFIXES = ['javascript', 'vbscript', 'datatext/html']; // M-5: `data:image/svg+xml` is blocked alongside `data:text/html` -- an SVG
// document can carry an inline <script>/onload= just like an HTML document,
// so it executes script when loaded as a document/navigation target (e.g. an
// <a href> or window.open), even though it's nominally an "image" MIME type.
// Other `data:image/*` types (png/jpeg/gif/webp, ...) stay allowed below --
// only this specific scriptable subtype is blocked, regardless of a
// trailing `;base64` or other parameters.
const DANGEROUS_SCHEME_PREFIXES = ['javascript', 'vbscript', 'datatext/html', 'dataimage/svg+xml'];
/** Decodes &#NN; and &#xNN; numeric HTML entities (used to obfuscate scheme names). */ /** Decodes &#NN; and &#xNN; numeric HTML entities (used to obfuscate scheme names). */
function decodeNumericEntities(s: string): string { function decodeNumericEntities(s: string): string {
@@ -57,20 +64,7 @@ export function safeUrl(s: string): string {
const trimmed = s.trim(); const trimmed = s.trim();
if (trimmed === '') return ''; if (trimmed === '') return '';
// Build a normalized copy for scheme detection only: decode numeric HTML const collapsed = normalizeForSchemeCheck(trimmed);
// entities (catches e.g. &#106; -> 'j'), strip whitespace/control chars,
// and lowercase.
const normalized = decodeNumericEntities(trimmed)
.replace(/[\x00-\x20]+/g, '')
.toLowerCase();
// Strip any colons before matching the scheme prefix. This defeats
// obfuscation that splices extra colons into the scheme name itself
// (e.g. decoding &#58; mid-word produces "java:script:alert(1)", which
// would otherwise dodge a literal "^javascript:" check) while still
// reliably catching the real "javascript:"/"vbscript:"/"data:text/html"
// prefixes once their own colon is removed.
const collapsed = normalized.replace(/:/g, '');
if (DANGEROUS_SCHEME_PREFIXES.some((prefix) => collapsed.startsWith(prefix))) { if (DANGEROUS_SCHEME_PREFIXES.some((prefix) => collapsed.startsWith(prefix))) {
return ''; return '';
@@ -79,6 +73,61 @@ export function safeUrl(s: string): string {
return trimmed; return trimmed;
} }
/**
* Image-context variant of `safeUrl` for IMAGE sinks only (`<img src>`, CSS
* `url(...)` backgrounds). `data:image/svg+xml` is safe here -- loaded as an
* image, an SVG is rasterized and never executes an inline <script>/onload=
* the way it would as a navigation/iframe document -- so, unlike `safeUrl`,
* this ALLOWS every `data:image/*` subtype (svg+xml, png, jpeg, gif, webp,
* ..., with or without `;base64`).
*
* Still blocks `javascript:` / `vbscript:` (same obfuscation-resistant
* normalization as `safeUrl`), and -- because this is an image sink, not a
* general-purpose URL sink -- treats `data:` as an ALLOWLIST rather than a
* blocklist: any `data:` URI whose type is NOT `image/*` (`data:text/html`,
* `data:application/javascript`, `data:text/javascript`, etc.) is blocked
* too, since none of those are legitimate image sources.
*
* Do NOT use this for href/iframe/form-action/navigation sinks -- keep
* those on `safeUrl`, which still blocks `data:image/svg+xml`.
*/
export function safeImageUrl(s: unknown): string {
if (typeof s !== 'string') return '';
const trimmed = s.trim();
if (trimmed === '') return '';
const collapsed = normalizeForSchemeCheck(trimmed);
if (collapsed.startsWith('javascript') || collapsed.startsWith('vbscript')) {
return '';
}
if (collapsed.startsWith('data') && !collapsed.startsWith('dataimage/')) {
// A data: URI whose MIME type isn't image/* -- e.g. data:text/html,
// data:application/javascript, data:text/javascript. No legitimate
// image source needs these; block unconditionally.
return '';
}
return trimmed;
}
// Normalizes a trimmed URL string for scheme-prefix matching only: decodes
// numeric HTML entities (catches e.g. &#106; -> 'j'), strips whitespace /
// control chars, lowercases, then strips every colon. Stripping colons
// defeats obfuscation that splices extra colons into the scheme name itself
// (e.g. decoding &#58; mid-word produces "java:script:alert(1)", which would
// otherwise dodge a literal "^javascript:" check) while still reliably
// catching the real "javascript:"/"vbscript:"/"data:..." prefixes once their
// own colon is removed. Used for prefix `.startsWith()` checks only -- the
// original (non-collapsed) string is always what gets returned/emitted.
function normalizeForSchemeCheck(trimmed: string): string {
return decodeNumericEntities(trimmed)
.replace(/[\x00-\x20]+/g, '')
.toLowerCase()
.replace(/:/g, '');
}
/** /**
* Neutralizes CSS-context breakout for a single design-token value (color, * Neutralizes CSS-context breakout for a single design-token value (color,
* length, gradient, etc.) so it is safe to interpolate RAW into either CSS * length, gradient, etc.) so it is safe to interpolate RAW into either CSS
@@ -151,8 +200,14 @@ export function stableHash(seed: string): string {
* available. * available.
*/ */
export function scopeId(nodeId: string | undefined, fallbackSeed: string, prefix: string): string { export function scopeId(nodeId: string | undefined, fallbackSeed: string, prefix: string): string {
const slug = (nodeId || '').toString().toLowerCase().replace(/[^a-z0-9]+/g, ''); // M-4: hash the raw node id (via the same djb2 `stableHash` used for the
return `${prefix}_${slug || stableHash(fallbackSeed)}`; // fallback path below) rather than lowercasing + stripping punctuation
// into a slug. A slug collapses distinct ids that differ only by
// case/punctuation (e.g. "AbC" and "abc", or "a-b" and "ab") onto the same
// scope; hashing the untouched string keeps them distinct while staying
// deterministic and producing a valid CSS ident (prefix + '_' + [a-z0-9]+).
const seed = (nodeId || '').toString();
return `${prefix}_${stableHash(seed || fallbackSeed)}`;
} }
// Shared enum allowlists for `toHtml` attribute sinks fed by props that are // Shared enum allowlists for `toHtml` attribute sinks fed by props that are
+6 -2
View File
@@ -1,5 +1,5 @@
import { CSSProperties } from 'react'; import { CSSProperties } from 'react';
import { escapeAttr, safeUrl } from './escape'; import { escapeAttr, safeImageUrl } from './escape';
const camelToKebab = (str: string): string => const camelToKebab = (str: string): string =>
str.replace(/[A-Z]/g, (m) => '-' + m.toLowerCase()); str.replace(/[A-Z]/g, (m) => '-' + m.toLowerCase());
@@ -41,8 +41,12 @@ function sanitizeCssValue(raw: string): string {
// Neutralize the url(...) reference: validate/strip the scheme and // Neutralize the url(...) reference: validate/strip the scheme and
// re-wrap in single quotes with the contents escaped for attribute // re-wrap in single quotes with the contents escaped for attribute
// safety. This is already fully safe, `;` and all. // safety. This is already fully safe, `;` and all.
// Image-context sink (background/mask/border-image url()): use
// safeImageUrl, not safeUrl -- a data:image/svg+xml background is safe
// (rasterized, never executed as a document) and must survive here, the
// same way it must survive on an <img src>.
const inner = m[2]; const inner = m[2];
out += `url('${escapeAttr(safeUrl(inner.trim()))}')`; out += `url('${escapeAttr(safeImageUrl(inner.trim()))}')`;
lastIndex = URL_RE.lastIndex; lastIndex = URL_RE.lastIndex;
} }
out += sanitizeBreakoutChars(raw.slice(lastIndex)); out += sanitizeBreakoutChars(raw.slice(lastIndex));