Compare commits

...
Author SHA1 Message Date
shadowdao 2066059c35 fix(builder): phase-2 design tweaks (landing home icon, decouple layer-hover from guides, neutral zone separator)
- PagesPanel: add fa-home glyph (8px, inherits badge text color) before
  "Landing" text in the landing-page badge, restoring the icon dropped
  in an earlier polish pass.
- editor.css: split [data-craft-hovered] and [data-layer-hovered] into
  distinct rules. Layer-hover now uses a solid 2px accent outline (vs
  the dashed structural guides) and is excluded from the .guides-off
  suppression list, so the Layers-panel hover->canvas locator still
  works when "Show guides" is off. Structural guides/hover
  ([data-craft-node], [data-craft-hovered]) remain correctly gated.
- Canvas.tsx: move the header/footer ZonePreview separator border to a
  .zone-preview-sep class, recolor it from amber
  (rgba(245,158,11,0.3)) to neutral (rgba(148,163,184,0.25)) to match
  the empty-state variant, and gate it on .guides-off via CSS
  descendant selector so it disappears with the rest of the guides.
2026-07-12 21:12:09 -07:00
shadowdaoandClaude Opus 4.8 473fe8d421 feat(builder): item 16 — context menu FA icon per action
Every menu item now has an FA icon (previously only "Ask Sitesmith" did,
leaving the rest visually misaligned): Duplicate fa-clone, Copy
fa-files-o, Paste fa-clipboard, Move Up/Down fa-arrow-up/down, Select
Parent fa-level-up, Delete fa-trash (kept its danger-red color). The
clipboard-group / structure-group separator already existed via
dividerAfter — no structural change needed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 20:56:21 -07:00
shadowdaoandClaude Opus 4.8 5d6ae7946b fix(builder): item 15 — default header nav no longer links nonexistent pages
DEFAULT_HEADER_STATE's Navbar linked Home/About/Services/Contact, but a
brand new site only has a Home page — About/Services/Contact were dead
links on first click. Simplified the default to Home + an inert "Get
Started" CTA (href: '#') rather than seeding placeholder pages nobody
asked for. Updated default-header.test.ts's HTML-export assertions to
match the new default (and assert the removed links are gone).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 20:56:11 -07:00
shadowdaoandClaude Opus 4.8 1c85ab93eb feat(builder): item 14 — Templates modal close button + category pill hover states
Close button (already an FA fa-times icon-button) gains a hover
background/color; category pills gain the same hover-background
treatment when not active (they were already at the brief's target 12px
font-size). Template card hover-lift (translateY(-2px) + shadow) was
already implemented — no change needed there.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 20:56:05 -07:00
shadowdaoandClaude Opus 4.8 67228f24b4 feat(builder): item 11 — Pages panel hierarchy: neutral header/footer rows, outline Landing badge, icon+tooltip actions
Header/Footer zone rows switched from a loud amber zoneButtonStyle to a
compact neutral zoneRowStyle in the same surface language as the page
list (accent border/bg only when active, matching the page list's own
"currently open" treatment). The "Appears on all pages" subtitle moved to
a tooltip; a fa-pencil hint fades in on row hover (`.zone-row-pencil` CSS
already shipped in the previous commit) and swaps to a check icon while
editing that zone.

The "LANDING" badge (loud amber uppercase + house icon) is now a small
outline badge — muted text, 1px border, no fill, no icon.

The page-row rename button's raw `&#9998;` HTML entity is now an FA
`fa-pencil` icon with `data-tooltip`/`aria-label`; the delete button
(already FA fa-trash) got the same tooltip/aria treatment for
consistency. Both dropped their native `title` in favor of `data-tooltip`
to avoid a double tooltip.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 20:56:00 -07:00
shadowdaoandClaude Opus 4.8 613a44c4d4 feat(builder): items 10+12+13 — scope canvas guides, Show Guides toggle, Layers panel hover/icons, topbar unification
Item 10: dashed canvas "guide" outlines were applied via blanket tag
selectors (div/section/header/...), so a finished section showed 3-4
nested dashed boxes. RenderNode.tsx (the <Editor onRender> override) now
tags each Craft.js droppable container's real DOM node with a
`data-craft-node` attribute (node.data.isCanvas, excluding ROOT), and
editor.css's guide rules target that attribute instead — a component's own
internal wrapper markup is no longer mistaken for a drop target. Added a
"Show guides" topbar toggle (default ON, persisted to localStorage),
state lifted in EditorShell.tsx (mirrors how `device` is already lifted),
flips `.guides-off` on Canvas.tsx's `.canvas-device-frame`.

Item 12: Layers panel rows get per-type FA icons (keyed off the same
craft.displayName used for the row label, seeded from BlocksPanel's
choices), indent-guide lines connecting nested rows, and row hover
highlights the matching canvas element (`data-layer-hovered`, written
directly to the node's DOM via `query.node(id).get().dom` — NOT via
`actions.setNodeEvent`, which is stripped from useEditor()'s public
`actions` at runtime, not just in its TS type, and threw when called).

Along the way, Craft.js's own connect() was found to already wire mouse
hover to the same `hovered` node event internally (previously invisible
because the matching CSS was dead) — RenderNode now also mirrors that
onto `data-craft-hovered` for a real-mouse-hover canvas highlight, and
both hover attributes plus the guide attribute are suppressed under
`.guides-off` so the toggle stays airtight.

Item 13: unified the topbar to one button radius (`.topbar-btn` 20px pill
-> var(--radius-md)), demoted Code/Preview to icon-only with
`data-tooltip`. Fixed a latent bug the new tooltips exposed: `[data-tooltip]`
tooltips render above their trigger, but the topbar has no room above it
(`body { overflow: hidden }`) — added a `.topbar` override to render
those below instead.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 20:55:52 -07:00
jknapp 5685670895 Merge PR #6: UI polish Phase 1 2026-07-13 03:22:51 +00:00
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
shadowdaoandClaude Opus 4.8 591a51dcc2 fix(builder): escape/allowlist all attribute-value sinks incl. numeric/enum props (XSS)
An adversarial pass found 5 Critical XSS sinks where props declared number/enum
in TypeScript were interpolated raw into exported HTML attribute values,
trusting the type — but nothing enforces it at runtime (AI update_props only
validates node_id; deserialized saved state is untyped JSON). Fixed all 5
(NumberCounter data-target, StarRating aria-label, FormContainer method,
ContactForm/InputField input type) plus 6 sibling sinks found by an exhaustive
audit of every attribute-value interpolation across src/components: a
JS-source injection into ContentSlider's inline setInterval script, a
prototype-pollution-adjacent allowlist gap in Section's divider-shape lookup,
TextareaField rows, Testimonials rating aria-label, HeroSimple textAlign, and
MapEmbed zoom. Adds shared sanitizeFormMethod/sanitizeInputType allowlist
helpers to utils/escape.ts alongside the existing escapeAttr/safeUrl/cssValue
primitives. Every fix is TDD'd: a malicious-value test reproduces the raw
injection against the pre-fix code, then passes after the fix.

502 tests green (npx vitest run), tsc + vite build green (npm run build).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 18:03:44 -07:00
shadowdaoandClaude Opus 4.8 7ba91d9829 fix(builder): sanitize non-string style values + allowlist element tags (XSS)
Adversarial re-review found the C1 fix incomplete plus an adjacent
same-class XSS, both reachable via the AI update_props path and
deserialized saved state:

- cssPropsToString only ran sanitizeCssValue on typeof-string values, so a
  non-string style value (array/object) with a valid key skipped
  sanitization entirely and was template-coerced raw into style="...",
  e.g. { color: ['red', '"><img src=x onerror=alert(1)>'] }. Now every
  non-number value is coerced with String() and sanitized; numbers stay
  raw. sanitizeBreakoutChars also now escapes < and > (previously only ;
  and ") as defense-in-depth, since values can reach it from non-string
  sources.

- props.tag (Container) and props.level (Heading) were interpolated raw
  into the tag position of exported HTML (`<${tag}`, `<${level}`) with no
  runtime validation, letting a malicious value break out of the tag
  entirely. Both are now allowlisted/clamped against their known-safe sets
  (div/section/article/header/footer/main; h1-h6), falling back to
  div/h2. Applied in Container's live render + toHtml, Heading's live
  render + toHtml, and the typeName==='div' fallback branch in
  html-export.ts's renderNode (hit for unresolved/legacy node types).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 17:44:59 -07:00
shadowdaoandClaude Opus 4.8 e12fb89ada Fix M-4: punctuation-only page name yields empty slug ('.html')
slugify('!!!') stripped down to '' since punctuation-only names have no
a-z0-9 characters left; buildSavePayload then wrote filename = '' +
'.html'. slugify now falls back to 'page' when the computed slug is
empty, so uniqueSlug's existing dedupe (page, page-2, ...) applies same
as any other collision. Landing page slug stays forced to 'index'.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 17:33:33 -07:00
shadowdaoandClaude Opus 4.8 25507cb57a Fix INT: ContentSlider Slides editor wrote wrong prop key (image vs imageSrc)
MediaStylePanel's Slides array editor guarded on item.image !== undefined
and wrote `image` via AssetPicker's onChange, but ContentSlider (render
+ toHtml) reads slide.imageSrc. Default slides (imageSrc:'', no `image`
key) never showed an image picker at all, and any `image` value written
was a silent no-op on render/export.

Editor now guards/reads/writes `imageSrc` throughout, and the
"add slide" emptyItem matches defaultSlides' exact shape
(type/imageSrc/heading/text/bgColor).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 17:32:47 -07:00
shadowdaoandClaude Opus 4.8 9d3bbc2c46 Fix I-2: dangling parent on shell/ColumnLayout-rooted AI-replace trees
treeToCraftState's ROOT re-key branch reparented nodes['ROOT'].nodes
children to 'ROOT' but not nodes['ROOT'].linkedNodes children. For a
ColumnLayout- or Section/BackgroundSection/FormContainer-rooted AI
`replace`, flattenTreeForCraft puts content in linkedNodes (col-N /
section-inner etc.) whose parent was left pointing at the OLD root id
-- which is then deleted, leaving a dangling parent reference that
breaks select/move/delete of those nodes in the Craft.js editor.

Now the same loop that reparents nodes[] children also reparents
linkedNodes children.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 17:31:01 -07:00
shadowdaoandClaude Opus 4.8 25e674badd Fix I-1: landing-page edits lost when activePageId goes dangling
buildSavePayload matched the active page by page.id === activePageId,
but activePageId defaults to the hardcoded 'home' and load() never
updated it. Deleting the original Home page (its replacement gets id
page_<ts>), reloading, editing, and saving would then match no page --
the live edit only reached the legacy top-level craft_state, while the
authoritative pages[]/pages_craft_state[0] for index.html fell back to
stale state.

Belt-and-suspenders fix:
- buildSavePayload now falls back to pages[0] as the effective active
  page when a real page is active but activePageId matches nothing.
- useWhpApi's load() now points activePageId at the freshly restored
  first page via a new PageContext setActivePageIdDirect setter
  (bookkeeping only -- no re-serialize/deserialize), so activePageId
  stays valid after every load.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 17:29:32 -07:00
shadowdaoandClaude Opus 4.8 e5f30a4a56 Fix C1/C2: XSS via unsanitized style keys and animation attrs
C1: cssPropsToString emitted the camelToKebab'd style-object KEY
unsanitized while only sanitizing the VALUE. A malicious style key
containing a quote (reachable via AI update_props or deserialized
saved state) could close the style="..." attribute and inject a live
element. Now validates each key against a CSS property/custom-prop
allowlist and drops anything that doesn't match.

C2: buildDataAttrs (html-export.ts) interpolated props.animation and
props.animationDelay directly into data-animation="..."/
data-animation-delay="..." with no escaping, for every exported node.
Now routes both through escapeAttr.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 17:27:22 -07:00
shadowdaoandClaude Opus 4.8 36c3b2f503 fix(builder): sanitize CSS-value sinks to prevent style/<style> breakout XSS
Adds a single cssValue() sanitizer (src/utils/escape.ts) that strips
<>{};"'\ and neutralizes url(), safe for both style="..." attribute and
<style>...</style> element contexts. Applies it at every raw user-prop
CSS-value interpolation sink found via grep across src/components (colors,
sizes, gaps interpolated directly into style strings/<style> blocks),
including the highest-risk <style>-context sinks: ColumnLayout gap,
Menu/Navbar hover and background colors. Also Number()-coerces the
`columns` grid-template-columns sinks in Gallery/Testimonials/NumberCounter
as defense in depth. Regression tests assert </style><script> payloads are
neutralized and normal colors still render.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 15:56:25 -07:00
shadowdaoandClaude Opus 4.8 5acf172511 docs(builder): update CLAUDE.md for hardening branch (remove dead settings/zones, asset picker)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 15:39:16 -07:00
shadowdaoandClaude Opus 4.8 ebb8fe1027 refactor(builder): type the AI-response/patch boundary
applyPatch's `ops` param was typed `any[]`, discarding the SitesmithPatchOp
discriminated union that SitesmithResponse.ops already carried at the call
site. Type it ops: SitesmithPatchOp[] so op.props/op.tree/op.op narrow
correctly per-variant in the switch instead of being `any`, and update the
unit tests' inline op literals to SitesmithPatchOp[] so tsc checks them too.

SerializedTreeNode/SitesmithPatchOp themselves, and buildNodeTree/
sanitizeAiTree's tree params, were already typed from a prior task -- this
closes the one remaining any at the op-handling site. Runtime validation
(protected-key guard, style-merge guard, unknown-resolvedName soft-skip) is
unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 15:30:56 -07:00
shadowdaoandClaude Opus 4.8 b88b242b6a refactor(builder): extract shared Modal component for the three editor modals
New src/ui/Modal.tsx ({ open, onClose, title?, children, width?,
closeOnEscape?, closeOnBackdropClick?, backdropStyle?, backdropProps? })
owns the backdrop, Escape-to-close, backdrop-click-to-close, and a new
body-scroll-lock while open. Adopted by TemplateModal, HeadCodeModal, and
SitesmithModal; each keeps its own panel styling/header/footer as children
since those differ per modal.

- TemplateModal: Escape/backdrop-click still close the confirm-template
  sub-dialog first via a wrapped onClose passed to Modal; the header's X
  button keeps using the raw onClose prop (always fully closes), matching
  prior asymmetric behavior.
- HeadCodeModal: straightforward adoption, no prior custom close logic.
- SitesmithModal: previously had no `open` prop, no Escape-to-close, and no
  backdrop-click-to-close. Preserved via open (always mounted-open by its
  parent already), closeOnEscape={false}, closeOnBackdropClick={false}; role
  and aria-modal are passed through via backdropProps to keep them on the
  same backdrop element as before.

Body scroll-lock while a modal is open is a small new addition (requested by
the task) applied uniformly; it has no visible effect since each modal's
opaque fixed-position backdrop already fully covers the viewport.

Added a light Modal.test.tsx (renders children, Escape/backdrop-click close
behavior, scroll-lock) using the existing react-dom/client + act harness.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 15:22:05 -07:00
shadowdaoandClaude Opus 4.8 8f51b5144a refactor(builder): extract shared ArrayItemFieldsEditor from SectionTypePanel/GenericPropsEditor
Both panels had a ~90-line near-byte-identical per-item field editor
(boolean->checkbox, number->number, /color/->color, long->textarea, else
text, fields derived from Object.keys(items[0])). Moved into a new
ArrayItemFieldsEditor component in ArrayItemFields.tsx used by both.
SectionTypePanel keeps its own FeaturesGrid special-case (key === 'features'
-> FeaturesEditor) and its empty-array guard ahead of the shared call.
Behavior-preserving (task E4.2).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 15:15:34 -07:00
shadowdaoandClaude Opus 4.8 4733fe84b5 refactor(builder): extract useNodeProp hook, dedup StylePanel setProp boilerplate
Adopted in BackgroundSectionStylePanel, ButtonStylePanel, ContainerStylePanel,
FormStylePanel, GenericPropsEditor, HeroStylePanel, ImageStylePanel,
MediaStylePanel, NavStylePanel, SectionTypePanel, SocialStylePanel,
TextStylePanel — only where the inline setProp/setPropStyle were behaviorally
identical to the hook. PricingStylePanel and FeaturesEditor keep their own
array-mutation logic since it differs. Behavior-preserving (task E4.1).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 15:13:35 -07:00
shadowdaoandClaude Opus 4.8 3cd4ad0154 test(builder): cover Ctrl+C/V shortcut id-regeneration and guards
Regression coverage for the safety-sensitive part of keyboard copy/paste:
nothing previously tested that Ctrl/Cmd+V regenerates node ids via
regenerateTreeIds before actions.addNodeTree -- the exact logic whose
absence caused a duplicate-id corruption bug. Also covers sibling-parent
targeting (with ROOT fallback), the empty-clipboard no-op, and the
existing input/contentEditable focus guard.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 15:08:46 -07:00
shadowdaoandClaude Opus 4.8 6421306849 feat(builder): implement Ctrl+C/V copy-paste shortcuts
The context menu advertised Ctrl+C/Ctrl+V hints but useKeyboardShortcuts
only handled undo/redo/delete/duplicate/escape -- pressing them did
nothing. Add a tiny shared module-level clipboard (src/hooks/clipboard.ts)
used by both the keyboard hook and the context menu so copying via one
entry point and pasting via the other stay consistent.

Ctrl/Cmd+C stores the selected node id (skipping ROOT). Ctrl/Cmd+V
inserts a copy as a sibling of the current selection via
regenerateTreeIds, mirroring the existing context-menu paste behavior.
Both respect the existing "disabled while typing" guard.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 15:01:30 -07:00
shadowdaoandClaude Opus 4.8 e40d55b2f2 chore(builder): point dev proxy at current test server
192.168.1.105 was decommissioned; local npm run dev save/load now
proxies /api to the current test server (whp-al10-test, .148).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 14:59:42 -07:00
shadowdaoandClaude Opus 4.8 1958b8e0b5 chore(builder): remove GuidedStyles classification cruft
Drop the computed-but-unused resolvedName/resolverMap, anchor the hero
regex (/^hero/i) so it stops matching any type name containing "hero",
and remove the isContainer alternations for the now-deleted
HeaderZone/FooterZone components.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 14:59:39 -07:00
124 changed files with 4394 additions and 879 deletions
+58 -46
View File
@@ -41,14 +41,12 @@ craft/
│ │ └── Canvas.tsx # Craft.js <Frame> with device-width switching │ │ └── Canvas.tsx # Craft.js <Frame> with device-width switching
│ │ │ │
│ ├── components/ │ ├── components/
│ │ ├── resolver.ts # Component map for Craft.js serialization (20 components) │ │ ├── resolver.ts # Component map for Craft.js serialization (39 components)
│ │ ├── layout/ │ │ ├── layout/
│ │ │ ├── Container.tsx # Generic container (div/section/article/header/footer/main) │ │ │ ├── Container.tsx # Generic container (div/section/article/header/footer/main)
│ │ │ ├── Section.tsx # Full-width section with centered inner container │ │ │ ├── Section.tsx # Full-width section with centered inner container
│ │ │ ├── ColumnLayout.tsx # Flex columns (1-6, with split ratios) │ │ │ ├── ColumnLayout.tsx # Flex columns (1-6, with split ratios)
│ │ │ ├── BackgroundSection.tsx # Section with background image/gradient overlay │ │ │ └── BackgroundSection.tsx # Section with background image/gradient overlay
│ │ │ ├── HeaderZone.tsx # Page-level header zone wrapper
│ │ │ └── FooterZone.tsx # Page-level footer zone wrapper
│ │ ├── basic/ │ │ ├── basic/
│ │ │ ├── Heading.tsx # Inline-editable heading (h1-h6) │ │ │ ├── Heading.tsx # Inline-editable heading (h1-h6)
│ │ │ ├── TextBlock.tsx # Inline-editable paragraph │ │ │ ├── TextBlock.tsx # Inline-editable paragraph
@@ -73,7 +71,8 @@ craft/
│ ├── panels/ │ ├── panels/
│ │ ├── topbar/ │ │ ├── topbar/
│ │ │ ├── TopBar.tsx # Back button, domain badge, device switcher, undo/redo, save, templates │ │ │ ├── TopBar.tsx # Back button, domain badge, device switcher, undo/redo, save, templates
│ │ │ └── TemplateModal.tsx # Template browser with categories and one-click loading │ │ │ ├── TemplateModal.tsx # Template browser with categories and one-click loading
│ │ │ └── HeadCodeModal.tsx # Edits SiteDesign.headCode (site-wide, not per-page)
│ │ ├── left/ │ │ ├── left/
│ │ │ ├── LeftPanel.tsx # Tabs: Blocks | Pages | Layers | Assets │ │ │ ├── LeftPanel.tsx # Tabs: Blocks | Pages | Layers | Assets
│ │ │ ├── BlocksPanel.tsx # Draggable block toolbox with categories │ │ │ ├── BlocksPanel.tsx # Draggable block toolbox with categories
@@ -81,9 +80,10 @@ craft/
│ │ │ ├── LayersPanel.tsx # Component hierarchy tree view │ │ │ ├── LayersPanel.tsx # Component hierarchy tree view
│ │ │ └── AssetsPanel.tsx # Asset browser with upload, drag-drop, thumbnails │ │ │ └── AssetsPanel.tsx # Asset browser with upload, drag-drop, thumbnails
│ │ ├── right/ │ │ ├── right/
│ │ │ ├── RightPanel.tsx # Tabs: Styles | Settings | Head │ │ │ ├── RightPanel.tsx # Single "Styles" tab -- renders GuidedStyles only
│ │ │ ├── GuidedStyles.tsx # Context-aware style panel (shows selected type) │ │ │ ├── GuidedStyles.tsx # Context-aware dispatcher: picks a StylePanel by selected type
│ │ │ └── SiteDesignPanel.tsx # Site-wide design tokens editor (Basic/Advanced tabs) │ │ │ ├── SiteDesignPanel.tsx # Site-wide design tokens editor (Basic/Advanced tabs)
│ │ │ └── styles/ # Per-type StylePanels (Text, Button, Image, Container, Nav, Form, etc.)
│ │ └── context-menu/ │ │ └── context-menu/
│ │ └── ContextMenu.tsx # Right-click context menu (duplicate, copy, paste, delete, etc.) │ │ └── ContextMenu.tsx # Right-click context menu (duplicate, copy, paste, delete, etc.)
│ │ │ │
@@ -98,7 +98,8 @@ craft/
│ │ └── definitions.ts # 16 template definitions across 4 categories │ │ └── definitions.ts # 16 template definitions across 4 categories
│ │ │ │
│ ├── ui/ │ ├── ui/
│ │ └── SettingsTabs.tsx # Reusable General/Style/Advanced tabs for component settings │ │ ├── AssetPicker.tsx # Reusable image/video source picker (upload/browse-uploaded/URL), full+compact variants
│ │ └── Modal.tsx # Reusable modal dialog shell
│ │ │ │
│ ├── constants/ │ ├── constants/
│ │ └── presets.ts # Color, font, spacing, radius, gradient, device width presets │ │ └── presets.ts # Color, font, spacing, radius, gradient, device width presets
@@ -165,7 +166,7 @@ The PHP wrapper (`/docker/whp/web/site-builder/index.php`) injects `WHP_CONFIG`
5. **API compatibility** - The save endpoint sends data in the same format as the GrapesJS version (`{ site_id, name, html, css, grapesjs: serializedJson }`), so the PHP backend doesn't need changes. 5. **API compatibility** - The save endpoint sends data in the same format as the GrapesJS version (`{ site_id, name, html, css, grapesjs: serializedJson }`), so the PHP backend doesn't need changes.
6. **Component-based architecture** - Each visual element is a React component that doubles as a Craft.js `UserComponent`. All rendering, settings UI, and HTML export are co-located in one file. 6. **Component-based architecture** - Each visual element is a React component that doubles as a Craft.js `UserComponent`. Rendering and HTML export are co-located in one file; style editing is handled by a shared per-type `StylePanel` in `src/panels/right/styles/` rather than a per-component settings panel.
7. **Site Design Tokens** - A `SiteDesignContext` provides 17 design properties (colors, fonts, radii, nav style) that components can reference. Templates import their own design tokens when loaded. 7. **Site Design Tokens** - A `SiteDesignContext` provides 17 design properties (colors, fonts, radii, nav style) that components can reference. Templates import their own design tokens when loaded.
@@ -188,28 +189,21 @@ export const MyComponent: UserComponent<MyComponentProps> = ({ text, style }) =>
return <div ref={(r) => { if (r) connect(drag(r)); }} style={style}>{text}</div>; return <div ref={(r) => { if (r) connect(drag(r)); }} style={style}>{text}</div>;
}; };
// 3. Settings panel (rendered in right panel when selected) // 3. Craft config (displayName, default props, rules)
const MyComponentSettings: React.FC = () => {
const { actions: { setProp }, props } = useNode((node) => ({
props: node.data.props as MyComponentProps,
}));
return <div>/* preset buttons, inputs, etc. */</div>;
};
// 4. Craft config (displayName, default props, rules, related settings)
MyComponent.craft = { MyComponent.craft = {
displayName: 'My Component', displayName: 'My Component',
props: { text: 'Default text', style: {} }, props: { text: 'Default text', style: {} },
rules: { canDrag: () => true, canMoveIn: () => false, canMoveOut: () => true }, rules: { canDrag: () => true, canMoveIn: () => false, canMoveOut: () => true },
related: { settings: MyComponentSettings },
}; };
// 5. HTML export (static method for serializing to HTML string) // 4. HTML export (static method for serializing to HTML string)
(MyComponent as any).toHtml = (props: MyComponentProps, childrenHtml: string) => { (MyComponent as any).toHtml = (props: MyComponentProps, childrenHtml: string) => {
return { html: `<div style="...">${childrenHtml}</div>` }; return { html: `<div style="...">${childrenHtml}</div>` };
}; };
``` ```
Style editing for the new type is added separately as a `StylePanel` under `src/panels/right/styles/` (or reuses an existing generic one), and wired into `GuidedStyles.tsx`'s type dispatch -- components no longer carry their own settings UI.
### Component Resolver ### Component Resolver
All components must be registered in `src/components/resolver.ts`. This map is passed to `<Editor resolver={componentResolver}>` so Craft.js can serialize/deserialize the node tree. All components must be registered in `src/components/resolver.ts`. This map is passed to `<Editor resolver={componentResolver}>` so Craft.js can serialize/deserialize the node tree.
@@ -270,7 +264,7 @@ The editor auto-saves every 30 seconds when running inside WHP. The save status
- `whpConfig` - The full config object (or null in standalone mode) - `whpConfig` - The full config object (or null in standalone mode)
- `isWHP` - Boolean shorthand for whether we're running inside WHP - `isWHP` - Boolean shorthand for whether we're running inside WHP
## All Components (22) ## All Components (39)
| # | Component | Type | File | Features | | # | Component | Type | File | Features |
|---|-----------|------|------|----------| |---|-----------|------|------|----------|
@@ -278,24 +272,41 @@ The editor auto-saves every 30 seconds when running inside WHP. The save status
| 2 | Section | Layout | `layout/Section.tsx` | Full-width with centered inner container, bg color/gradient, vertical padding, inner max-width | | 2 | Section | Layout | `layout/Section.tsx` | Full-width with centered inner container, bg color/gradient, vertical padding, inner max-width |
| 3 | ColumnLayout | Layout | `layout/ColumnLayout.tsx` | 1-6 columns, split ratios (50-50, 30-70, 70-30, 33-33-33, 25-25-25-25, etc.), gap control | | 3 | ColumnLayout | Layout | `layout/ColumnLayout.tsx` | 1-6 columns, split ratios (50-50, 30-70, 70-30, 33-33-33, 25-25-25-25, etc.), gap control |
| 4 | BackgroundSection | Layout | `layout/BackgroundSection.tsx` | Section with background image, gradient overlay, parallax-ready | | 4 | BackgroundSection | Layout | `layout/BackgroundSection.tsx` | Section with background image, gradient overlay, parallax-ready |
| 5 | HeaderZone | Layout | `layout/HeaderZone.tsx` | Page-level header wrapper zone, used by PageContext | | 5 | Heading | Basic | `basic/Heading.tsx` | Inline-editable, h1-h6 level, color, font family/size/weight, text align |
| 6 | FooterZone | Layout | `layout/FooterZone.tsx` | Page-level footer wrapper zone, used by PageContext | | 6 | TextBlock | Basic | `basic/TextBlock.tsx` | Inline-editable paragraph, color, font family/size/weight, text align, line height |
| 7 | Heading | Basic | `basic/Heading.tsx` | Inline-editable, h1-h6 level, color, font family/size/weight, text align | | 7 | ButtonLink | Basic | `basic/ButtonLink.tsx` | Link text/URL/target, 8 color presets (auto text contrast), radius, padding, font size |
| 8 | TextBlock | Basic | `basic/TextBlock.tsx` | Inline-editable paragraph, color, font family/size/weight, text align, line height | | 8 | Logo | Basic | `basic/Logo.tsx` | Text or image logo, link href, font family/size/weight, image width |
| 9 | ButtonLink | Basic | `basic/ButtonLink.tsx` | Link text/URL/target, 8 color presets (auto text contrast), radius, padding, font size | | 9 | Menu | Basic | `basic/Menu.tsx` | Link list with optional CTA styling, horizontal/vertical orientation, alignment, hover colors |
| 10 | Navbar | Basic | `basic/Navbar.tsx` | Text or image logo, page links, external links, CTA buttons, light/dark nav style | | 10 | Navbar | Basic | `basic/Navbar.tsx` | Text or image logo, page links, external links, CTA buttons, light/dark nav style |
| 11 | Footer | Basic | `basic/Footer.tsx` | Footer with links, copyright, social links | | 11 | Footer | Basic | `basic/Footer.tsx` | Footer with links, copyright, social links |
| 12 | Divider | Basic | `basic/Divider.tsx` | Horizontal rule with color and thickness controls | | 12 | Divider | Basic | `basic/Divider.tsx` | Horizontal rule with color and thickness controls |
| 13 | Spacer | Basic | `basic/Spacer.tsx` | Vertical spacing element with height control | | 13 | Spacer | Basic | `basic/Spacer.tsx` | Vertical spacing element with height control |
| 14 | ImageBlock | Media | `media/ImageBlock.tsx` | SVG placeholder, URL input, upload, browse assets, alt text, width/height, object-fit, radius | | 14 | Icon | Basic | `basic/Icon.tsx` | Font Awesome icon, size/color, background shape, optional link |
| 15 | VideoBlock | Media | `media/VideoBlock.tsx` | YouTube, Vimeo, direct files (.mp4/.webm/.ogg), background mode, autoplay, loop | | 15 | ImageBlock | Media | `media/ImageBlock.tsx` | SVG placeholder, URL input, upload, browse assets, alt text, width/height, object-fit, radius |
| 16 | HeroSimple | Section | `sections/HeroSimple.tsx` | Pre-built hero with heading, subtext, CTA button, gradient/color background | | 16 | VideoBlock | Media | `media/VideoBlock.tsx` | YouTube, Vimeo, direct files (.mp4/.webm/.ogg), background mode, autoplay, loop |
| 17 | FeaturesGrid | Section | `sections/FeaturesGrid.tsx` | 3-column feature cards with icons, titles, descriptions | | 17 | MapEmbed | Media | `media/MapEmbed.tsx` | Embedded map by address, zoom level, height |
| 18 | CTASection | Section | `sections/CTASection.tsx` | Call-to-action banner with heading, text, button | | 18 | HeroSimple | Section | `sections/HeroSimple.tsx` | Pre-built hero with heading, subtext, CTA button, gradient/color background |
| 19 | FormContainer | Form | `forms/FormContainer.tsx` | Form wrapper with action URL and method | | 19 | FeaturesGrid | Section | `sections/FeaturesGrid.tsx` | 3-column feature cards with icons, titles, descriptions |
| 20 | InputField | Form | `forms/InputField.tsx` | Text input with label, placeholder, type (text/email/tel/password/number) | | 20 | CTASection | Section | `sections/CTASection.tsx` | Call-to-action banner with heading, text, button |
| 21 | TextareaField | Form | `forms/TextareaField.tsx` | Textarea with label and placeholder | | 21 | Countdown | Section | `sections/Countdown.tsx` | Countdown timer to a target date, heading, digit/label colors |
| 22 | FormButton | Form | `forms/FormButton.tsx` | Submit button with color and style controls | | 22 | Testimonials | Section | `sections/Testimonials.tsx` | Grid or single-layout testimonial cards, star color, card background |
| 23 | FormContainer | Form | `forms/FormContainer.tsx` | Form wrapper with action URL and method |
| 24 | InputField | Form | `forms/InputField.tsx` | Text input with label, placeholder, type (text/email/tel/password/number) |
| 25 | TextareaField | Form | `forms/TextareaField.tsx` | Textarea with label and placeholder |
| 26 | FormButton | Form | `forms/FormButton.tsx` | Submit button with color and style controls |
| 27 | ContactForm | Form | `forms/ContactForm.tsx` | Configurable field list, recipient email, success message or thank-you URL redirect |
| 28 | StarRating | Basic | `basic/StarRating.tsx` | Star rating display, rating/max stars, filled/empty color |
| 29 | SocialLinks | Basic | `basic/SocialLinks.tsx` | Social icon links, size/color/shape, gap, alignment |
| 30 | CallToAction | Section | `sections/CallToAction.tsx` | Heading/description with 1-2 buttons, color/gradient/image background with overlay |
| 31 | Accordion | Section | `sections/Accordion.tsx` | Expand/collapse item list, header/content colors |
| 32 | Tabs | Section | `sections/Tabs.tsx` | Tabbed content panels, active/inactive tab colors |
| 33 | PricingTable | Section | `sections/PricingTable.tsx` | Pricing plan cards, featured plan highlight, bullet list |
| 34 | Gallery | Section | `sections/Gallery.tsx` | Image grid gallery, configurable columns/gap, optional lightbox |
| 35 | ContentSlider | Section | `sections/ContentSlider.tsx` | Auto-playing image/content slider, dots/arrows, configurable interval |
| 36 | NumberCounter | Section | `sections/NumberCounter.tsx` | Animated stat counters, columns, number/label colors |
| 37 | SubscribeForm | Form | `forms/SubscribeForm.tsx` | Inline/stacked email signup form, heading, button color |
| 38 | SearchBar | Basic | `basic/SearchBar.tsx` | Search input with optional button, placeholder text |
| 39 | HtmlBlock | Basic | `basic/HtmlBlock.tsx` | Raw/custom HTML embed block, sanitized on export |
## Site Design Tokens ## Site Design Tokens
@@ -351,7 +362,7 @@ Templates are loaded via the Template Modal (opened from TopBar). Loading a temp
## Multi-Page System ## Multi-Page System
Pages are managed through `PageContext`: Pages are managed through `PageContext`:
- Each page has: `id`, `name`, `slug`, `craftState`, `headCode` - Each page has: `id`, `name`, `slug`, `craftState` (`headCode` is site-wide only, on `SiteDesignContext`/`SiteDesign`, edited via the TopBar's Head Code modal -- not a per-page field)
- Header and Footer are stored as separate "page" entries with fixed IDs (`__header__`, `__footer__`) - Header and Footer are stored as separate "page" entries with fixed IDs (`__header__`, `__footer__`)
- Page switching serializes the current canvas, stores it, then deserializes the target page - Page switching serializes the current canvas, stores it, then deserializes the target page
- Header/Footer editing puts the canvas in a distinct mode - Header/Footer editing puts the canvas in a distinct mode
@@ -392,21 +403,22 @@ The Assets panel (`AssetsPanel.tsx`) provides:
- Delete asset - Delete asset
- Integration with WHP API for server-side storage - Integration with WHP API for server-side storage
Image and Video components also have inline asset selection (browse button in settings). Image and video fields elsewhere in the editor (ImageStylePanel, MediaStylePanel, HeroStylePanel, NavStylePanel, BackgroundSectionStylePanel, and array-editor cards like FeaturesEditor) use the shared `AssetPicker` (`src/ui/AssetPicker.tsx`) for upload / browse-uploaded / paste-URL, in a `full` or `compact` variant depending on space.
## Adding New Components ## Adding New Components
1. Create `src/components/<category>/<ComponentName>.tsx` following the pattern above 1. Create `src/components/<category>/<ComponentName>.tsx` following the pattern above
2. Add the component to `src/components/resolver.ts` 2. Add the component to `src/components/resolver.ts`
3. Add a block entry in `src/panels/left/BlocksPanel.tsx` under the appropriate category 3. Add a block entry in `src/panels/left/BlocksPanel.tsx` under the appropriate category
4. Implement the `toHtml` static for HTML export 4. Add or extend a `StylePanel` in `src/panels/right/styles/` and wire it into `GuidedStyles.tsx`'s type dispatch so the new component is editable when selected
5. Build and test: `npm run dev`, drag the block onto the canvas, verify settings panel, verify HTML export 5. Implement the `toHtml` static for HTML export
6. Build and test: `npm run dev`, drag the block onto the canvas, verify the style panel, verify HTML export
### Checklist for a new component: ### Checklist for a new component:
- [ ] Props interface with `style?: CSSProperties` - [ ] Props interface with `style?: CSSProperties`
- [ ] `useNode()` with `connect(drag(ref))` on the root element - [ ] `useNode()` with `connect(drag(ref))` on the root element
- [ ] Settings panel using `useNode()` with `setProp()` - [ ] `StylePanel` entry (new or reused) wired into `GuidedStyles.tsx`
- [ ] `.craft` config with `displayName`, default `props`, `rules`, `related.settings` - [ ] `.craft` config with `displayName`, default `props`, `rules`
- [ ] `.toHtml()` static method using `cssPropsToString()` - [ ] `.toHtml()` static method using `cssPropsToString()`
- [ ] Registered in `resolver.ts` - [ ] Registered in `resolver.ts`
- [ ] Block added to `BlocksPanel.tsx` - [ ] Block added to `BlocksPanel.tsx`
@@ -421,7 +433,7 @@ The editor uses a dark theme defined via CSS custom properties in `src/styles/ed
- **Border:** `#2d2d3a` - **Border:** `#2d2d3a`
- **Font:** Inter - **Font:** Inter
All editor chrome (panels, topbar, settings) is styled via `editor.css`. User content on the canvas uses inline styles exclusively. All editor chrome (panels, topbar, style panels) is styled via `editor.css`. User content on the canvas uses inline styles exclusively.
## Presets ## Presets
@@ -446,7 +458,7 @@ Every component has a static `toHtml(props, childrenHtml)` method. The `html-exp
## Testing Approach ## Testing Approach
- **Manual testing:** Run `npm run dev`, drag components, edit props, verify settings panels - **Manual testing:** Run `npm run dev`, drag components, edit props, verify style panels
- **Type checking:** `tsc --noEmit` (part of build step) - **Type checking:** `tsc --noEmit` (part of build step)
- **HTML export:** Verify `toHtml()` output matches expected HTML structure - **HTML export:** Verify `toHtml()` output matches expected HTML structure
- **Device preview:** Switch between desktop/tablet/mobile and verify responsive behavior - **Device preview:** Switch between desktop/tablet/mobile and verify responsive behavior
@@ -455,9 +467,9 @@ Every component has a static `toHtml(props, childrenHtml)` method. The `html-exp
## Development Notes ## Development Notes
- Path alias `@/` maps to `./src/` (configured in both tsconfig.json and vite.config.ts) - Path alias `@/` maps to `./src/` (configured in both tsconfig.json and vite.config.ts)
- `GuidedStyles` shows selected component type and delegates to per-component settings panels - `GuidedStyles` shows the selected component type and dispatches to a shared `StylePanel` in `src/panels/right/styles/` (components no longer carry their own settings UI)
- Text components (Heading, TextBlock) use `contentEditable` for inline editing when selected - Text components (Heading, TextBlock) use `contentEditable` for inline editing when selected
- Button/link navigation is prevented in the editor via `e.preventDefault()` - Button/link navigation is prevented in the editor via `e.preventDefault()`
- Image upload integrates with WHP API; in standalone mode falls back to local `blob:` URLs - Image upload integrates with WHP API; in standalone mode falls back to local `blob:` URLs
- Auto-save runs every 30 seconds when connected to WHP API - Auto-save runs every 30 seconds when connected to WHP API
- The SettingsTabs UI component provides a reusable General/Style/Advanced tab layout for component settings - `AssetPicker` (`src/ui/AssetPicker.tsx`) is the shared upload/browse/URL control reused across StylePanels for every image and video field
+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 />
@@ -0,0 +1,54 @@
import { describe, test, expect } from 'vitest';
import { ButtonLink } from './ButtonLink';
const toHtml = (ButtonLink as any).toHtml;
describe('ButtonLink.toHtml href sanitization (attacker-controlled `href` prop)', () => {
test('a javascript: URL is neutralized', () => {
const { html } = toHtml({ href: 'javascript:alert(1)', text: 'Click' }, '');
expect(html).not.toContain('javascript:alert');
});
test('a quote-breakout href does not escape the href attribute', () => {
const malicious = '"><script>alert(1)</script>';
const { html } = toHtml({ href: malicious, text: 'Click' }, '');
expect(html).not.toContain('<script>alert(1)</script>');
});
test('a normal href still renders correctly', () => {
const { html } = toHtml({ href: 'https://example.com', text: 'Click' }, '');
expect(html).toContain('href="https://example.com"');
});
});
describe('ButtonLink.toHtml target (boolean-gated, not raw interpolation)', () => {
test('an attribute-breakout value for target does not reach the output raw', () => {
const malicious = '_blank" onmouseover="alert(1)' as any;
const { html } = toHtml({ href: '#', text: 'x', target: malicious }, '');
expect(html).not.toContain('onmouseover');
});
test('target="_blank" still adds rel=noopener noreferrer', () => {
const { html } = toHtml({ href: '#', text: 'x', target: '_blank' }, '');
expect(html).toContain('target="_blank"');
expect(html).toContain('rel="noopener noreferrer"');
});
});
describe('ButtonLink.toHtml text escaping (attacker-controlled `text` prop)', () => {
test('a tag-breakout attempt in text is neutralized (no injected element)', () => {
const { html } = toHtml({ href: '#', text: '</a><img src=x onerror=alert(1)>' }, '');
expect(html).not.toContain('<img');
expect(html).toContain('&lt;img');
});
test('ampersand is escaped for well-formed text content (consistency with escapeHtml)', () => {
const { html } = toHtml({ href: '#', text: 'Tom & Jerry' }, '');
expect(html).toContain('Tom &amp; Jerry');
});
test('a normal text value still renders unchanged', () => {
const { html } = toHtml({ href: '#', text: 'Click Me' }, '');
expect(html).toContain('>Click Me</a>');
});
});
+2 -2
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 { escapeAttr, safeUrl } from '../../utils/escape'; import { escapeHtml, escapeAttr, safeUrl } from '../../utils/escape';
interface ButtonLinkProps { interface ButtonLinkProps {
text?: string; text?: string;
@@ -78,7 +78,7 @@ ButtonLink.craft = {
textDecoration: 'none', textDecoration: 'none',
...props.style, ...props.style,
}); });
const escapedText = (props.text || '').replace(/</g, '&lt;').replace(/>/g, '&gt;'); const escapedText = escapeHtml(props.text || '');
const targetAttr = props.target === '_blank' ? ' target="_blank" rel="noopener noreferrer"' : ''; const targetAttr = props.target === '_blank' ? ' target="_blank" rel="noopener noreferrer"' : '';
return { return {
html: `<a href="${escapeAttr(safeUrl(props.href || '#'))}"${targetAttr}${styleStr ? ` style="${styleStr}"` : ''}>${escapedText}</a>`, html: `<a href="${escapeAttr(safeUrl(props.href || '#'))}"${targetAttr}${styleStr ? ` style="${styleStr}"` : ''}>${escapedText}</a>`,
@@ -0,0 +1,34 @@
import { describe, test, expect } from 'vitest';
import { Divider } from './Divider';
const toHtml = (Divider as any).toHtml;
describe('Divider.toHtml normal rendering', () => {
test('renders thickness/color into the border-top style', () => {
const { html } = toHtml({ thickness: '2px', color: '#ff0000' }, '');
expect(html).toContain('border-top:2px solid #ff0000');
});
});
describe('Divider.toHtml XSS hardening (thickness/color into style=)', () => {
test('a thickness value with an attribute-breakout string cannot escape style=""', () => {
const malicious = '1px" onmouseover="alert(1)';
const { html } = toHtml({ thickness: malicious as any, color: '#000' }, '');
// The quote must not survive unescaped -- otherwise it closes style=""
// early and "onmouseover" becomes a live, attacker-controlled attribute.
expect(html).not.toMatch(/"\s+onmouseover="/);
expect(html).not.toMatch(/style="[^"]*"[^>]*onmouseover/);
});
test('a color value with a </style><script> breakout is neutralized', () => {
const malicious = '#000</style><script>alert(1)</script>';
const { html } = toHtml({ thickness: '1px', color: malicious as any }, '');
expect(html).not.toContain('<script>alert(1)</script>');
});
test('a non-string thickness (object) does not raw-splice into style=""', () => {
const malicious = { toString: () => '1px" onmouseover="alert(1)' };
const { html } = toHtml({ thickness: malicious as any, color: '#000' }, '');
expect(html).not.toMatch(/"\s+onmouseover="/);
});
});
@@ -0,0 +1,22 @@
import { describe, test, expect } from 'vitest';
import { Footer } from './Footer';
const toHtml = (Footer as any).toHtml;
describe('Footer.toHtml text escaping (attacker-controlled `text` prop)', () => {
test('a tag-breakout attempt in text is neutralized (no injected element)', () => {
const { html } = toHtml({ text: '</footer><img src=x onerror=alert(1)>' }, '');
expect(html).not.toContain('<img');
expect(html).toContain('&lt;img');
});
test('ampersand is escaped for well-formed text content (consistency with escapeHtml)', () => {
const { html } = toHtml({ text: 'Terms & Conditions' }, '');
expect(html).toContain('Terms &amp; Conditions');
});
test('a normal copyright text value still renders unchanged', () => {
const { html } = toHtml({ text: '© 2026 MySite. All rights reserved.' }, '');
expect(html).toContain('© 2026 MySite. All rights reserved.');
});
});
+2 -1
View File
@@ -1,6 +1,7 @@
import React, { CSSProperties, useCallback, useRef, useEffect } from 'react'; import React, { CSSProperties, useCallback, useRef, useEffect } 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 } from '../../utils/escape';
interface FooterProps { interface FooterProps {
text?: string; text?: string;
@@ -107,6 +108,6 @@ Footer.craft = {
textAlign: 'center', textAlign: 'center',
...props.style, ...props.style,
}); });
const escapedText = (props.text || '').replace(/</g, '&lt;').replace(/>/g, '&gt;'); const escapedText = escapeHtml(props.text || '');
return { html: `<footer${styleStr ? ` style="${styleStr}"` : ''}>${escapedText}</footer>` }; return { html: `<footer${styleStr ? ` style="${styleStr}"` : ''}>${escapedText}</footer>` };
}; };
@@ -0,0 +1,58 @@
import { describe, test, expect } from 'vitest';
import { Heading } from './Heading';
const toHtml = (Heading as any).toHtml;
describe('Heading.toHtml level allowlist (adversarial re-review, same class as C1)', () => {
test('a malicious level value clamps to h2 -- no injected <img>, no broken-out tag', () => {
const { html } = toHtml({ text: 'x', level: 'h2><img src=x onerror=alert(1)' }, '');
expect(html).not.toContain('<img');
expect(html).not.toContain('onerror');
expect(html.startsWith('<h2')).toBe(true);
expect(html.endsWith('</h2>')).toBe(true);
});
test('a numeric out-of-range level (99) clamps to h2', () => {
const { html } = toHtml({ text: 'x', level: 99 as any }, '');
expect(html.startsWith('<h2')).toBe(true);
expect(html.endsWith('</h2>')).toBe(true);
});
test('a non-heading string level clamps to h2', () => {
const { html } = toHtml({ text: 'x', level: 'script' as any }, '');
expect(html.startsWith('<h2')).toBe(true);
expect(html).not.toContain('<script');
});
test('a normal valid level (h4) still emits <h4', () => {
const { html } = toHtml({ text: 'x', level: 'h4' }, '');
expect(html).toContain('<h4');
expect(html).toContain('</h4>');
});
test('all valid levels h1-h6 still work', () => {
for (const level of ['h1', 'h2', 'h3', 'h4', 'h5', 'h6']) {
const { html } = toHtml({ text: 'x', level }, '');
expect(html.startsWith(`<${level}`)).toBe(true);
expect(html.endsWith(`</${level}>`)).toBe(true);
}
});
});
describe('Heading.toHtml text escaping (attacker-controlled `text` prop)', () => {
test('a tag-breakout attempt in text is neutralized (no injected element)', () => {
const { html } = toHtml({ text: '</h2><img src=x onerror=alert(1)>', level: 'h2' }, '');
expect(html).not.toContain('<img');
expect(html).toContain('&lt;img');
});
test('ampersand is escaped for well-formed text content (consistency with escapeHtml)', () => {
const { html } = toHtml({ text: 'Fish & Chips', level: 'h2' }, '');
expect(html).toContain('Fish &amp; Chips');
});
test('a normal text value still renders unchanged', () => {
const { html } = toHtml({ text: 'Hello world', level: 'h2' }, '');
expect(html).toBe('<h2>Hello world</h2>');
});
});
+15 -3
View File
@@ -1,9 +1,20 @@
import React, { CSSProperties, useCallback, useRef, useEffect } from 'react'; import React, { CSSProperties, useCallback, useRef, useEffect } 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 } from '../../utils/escape';
type HeadingLevel = 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6'; type HeadingLevel = 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6';
// `level` is settable via the AI `update_props` path and from deserialized
// saved state -- neither type-checked at runtime -- and is interpolated
// directly into the tag position (`React.createElement(level, ...)` /
// `<${tag}` in `toHtml`). A malicious value like `h2><img src=x
// onerror=alert(1)` (or a non-h1-6 string) must never reach that position
// unchecked. Anything not in this allowlist clamps to `'h2'`.
const ALLOWED_HEADING_LEVELS = ['h1', 'h2', 'h3', 'h4', 'h5', 'h6'] as const;
const sanitizeHeadingLevel = (level: unknown): HeadingLevel =>
(ALLOWED_HEADING_LEVELS as readonly unknown[]).includes(level) ? (level as HeadingLevel) : 'h2';
interface HeadingProps { interface HeadingProps {
text?: string; text?: string;
level?: HeadingLevel; level?: HeadingLevel;
@@ -30,6 +41,7 @@ export const Heading: UserComponent<HeadingProps> = ({
selected: node.events.selected, selected: node.events.selected,
})); }));
const safeLevel = sanitizeHeadingLevel(level);
const elRef = useRef<HTMLElement | null>(null); const elRef = useRef<HTMLElement | null>(null);
const editedTextRef = useRef<string | null>(null); const editedTextRef = useRef<string | null>(null);
@@ -59,7 +71,7 @@ export const Heading: UserComponent<HeadingProps> = ({
} }
}, [text, selected]); }, [text, selected]);
return React.createElement(level, { return React.createElement(safeLevel, {
ref: (ref: HTMLElement | null): void => { ref: (ref: HTMLElement | null): void => {
elRef.current = ref; elRef.current = ref;
if (ref) connect(drag(ref)); if (ref) connect(drag(ref));
@@ -98,8 +110,8 @@ Heading.craft = {
}; };
(Heading as any).toHtml = (props: HeadingProps, _childrenHtml: string) => { (Heading as any).toHtml = (props: HeadingProps, _childrenHtml: string) => {
const tag = props.level || 'h2'; const tag = sanitizeHeadingLevel(props.level);
const safeText = (props.text || '').replace(/</g, '&lt;').replace(/>/g, '&gt;'); const safeText = escapeHtml(props.text || '');
const styleStr = cssPropsToString(props.style); const styleStr = cssPropsToString(props.style);
return { html: `<${tag}${styleStr ? ` style="${styleStr}"` : ''}>${safeText}</${tag}>` }; return { html: `<${tag}${styleStr ? ` style="${styleStr}"` : ''}>${safeText}</${tag}>` };
}; };
@@ -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>');
});
});
@@ -10,4 +10,16 @@ describe('HtmlBlock.toHtml sanitizes raw code (A4.1)', () => {
expect(html).not.toContain('onclick'); expect(html).not.toContain('onclick');
expect(html).toContain('<p>hi</p>'); expect(html).toContain('<p>hi</p>');
}); });
test('does not wrap output in an unsanitized element carrying the style prop raw', () => {
// toHtml only ever returns the sanitized `code` blob -- there is no
// wrapper <div style="..."> in the exported HTML, so a malicious
// `style` prop (e.g. an attacker-controlled object with a breakout
// toString()) has nothing to splice into.
const malicious = { toString: () => 'color:red" onmouseover="alert(1)' } as any;
const { html } = toHtml({ code: '<p>hi</p>', style: malicious }, '');
expect(html).not.toMatch(/onmouseover/);
expect(html).not.toMatch(/<div/);
expect(html).toBe('<p>hi</p>');
});
}); });
+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 = {} }) => {
@@ -0,0 +1,46 @@
import { describe, test, expect } from 'vitest';
import { Icon } from './Icon';
const toHtml = (Icon as any).toHtml;
describe('Icon.toHtml normal rendering', () => {
test('renders icon class, size/color style, and link href', () => {
const { html } = toHtml({ icon: 'fa-star', size: '32px', color: '#3b82f6', link: 'https://example.com' }, '');
expect(html).toContain('class="fa fa-star"');
expect(html).toContain('font-size:32px');
expect(html).toContain('color:#3b82f6');
expect(html).toContain('href="https://example.com"');
});
});
describe('Icon.toHtml XSS hardening', () => {
test('an icon name with an attribute-breakout string is escaped, not raw-concatenated', () => {
const malicious = 'star"><script>alert(1)</script>';
const { html } = toHtml({ icon: malicious as any }, '');
expect(html).not.toContain('<script>alert(1)</script>');
expect(html).not.toMatch(/class="fa star"><script>/);
});
test('a size value with an attribute-breakout string cannot escape style=""', () => {
const malicious = '24px" onerror="alert(1)';
const { html } = toHtml({ size: malicious as any }, '');
expect(html).not.toMatch(/"\s+onerror="/);
});
test('a bgSize/bgColor breakout via background wrapper is neutralized', () => {
const malicious = '56px" onmouseover="alert(1)';
const { html } = toHtml({ bgShape: 'circle', bgColor: '#fff', bgSize: malicious as any }, '');
expect(html).not.toMatch(/"\s+onmouseover="/);
});
test('a javascript: link is neutralized to an empty href', () => {
const { html } = toHtml({ link: 'javascript:alert(1)' }, '');
expect(html).not.toContain('javascript:alert(1)');
});
test('a link value with an attribute-breakout string cannot escape href=""', () => {
const malicious = 'https://example.com" onclick="alert(1)';
const { html } = toHtml({ link: malicious as any }, '');
expect(html).not.toMatch(/"\s+onclick="/);
});
});
@@ -0,0 +1,62 @@
import { describe, test, expect } from 'vitest';
import { Logo } from './Logo';
/*
* Regression coverage for Logo.toHtml -- audited during the toHtml
* attribute-XSS sweep (see task-cssxss-brief.md) and found already fully
* sanitized (href/src via escapeAttr(safeUrl()), alt/text via escapeAttr /
* escapeHtml, imageWidth/fontSize/etc. routed through cssPropsToString which
* sanitizes every value regardless of declared type). No fix was required;
* these tests lock that behavior in against regressions.
*/
const toHtml = (Logo as any).toHtml;
describe('Logo.toHtml href sanitization (attacker-controlled `href` prop)', () => {
test('a javascript: URL is neutralized', () => {
const { html } = toHtml({ href: 'javascript:alert(1)' }, '');
expect(html).not.toContain('javascript:alert');
});
test('a quote-breakout href does not escape the anchor attribute', () => {
const malicious = '"><script>alert(1)</script>';
const { html } = toHtml({ href: malicious }, '');
expect(html).not.toContain('<script>alert(1)</script>');
});
});
describe('Logo.toHtml image src/alt sanitization (type="image")', () => {
test('a javascript: imageSrc is neutralized', () => {
const { html } = toHtml({ type: 'image', imageSrc: 'javascript:alert(1)', text: 'Logo' }, '');
expect(html).not.toContain('javascript:alert');
});
test('a quote-breakout alt (from `text`) does not escape the img attribute', () => {
const malicious = '"><script>alert(1)</script>';
const { html } = toHtml({ type: 'image', imageSrc: 'https://example.com/logo.png', text: malicious }, '');
expect(html).not.toContain('<script>alert(1)</script>');
});
test('a non-numeric imageWidth (attribute-breakout attempt) does not escape the style attribute', () => {
const malicious = '1"><script>alert(1)</script>';
const { html } = toHtml({ type: 'image', imageSrc: 'https://example.com/logo.png', imageWidth: malicious }, '');
expect(html).not.toContain('<script>alert(1)</script>');
});
});
describe('Logo.toHtml text-logo styling sanitization', () => {
test('a quote-breakout color does not escape the span style attribute', () => {
const malicious = 'red" onmouseover="alert(1)';
const { html } = toHtml({ type: 'text', text: 'MySite', color: malicious }, '');
// The raw `"` must never survive un-escaped inside the style attribute
// value -- if it did, `onmouseover` would land as a REAL new HTML
// attribute (breakout) rather than being inert CSS-value garbage inside
// a properly-escaped style="...".
expect(html).not.toMatch(/style="[^"]*"[^>]*onmouseover/);
});
test('a normal logo renders as expected', () => {
const { html } = toHtml({ type: 'text', text: 'MySite', href: '/' }, '');
expect(html).toContain('href="/"');
expect(html).toContain('MySite');
});
});
+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',
@@ -30,3 +30,17 @@ describe('Menu.toHtml deterministic + unique scope ids (thread node id, no Math.
expect(html1).toBe(html2); expect(html1).toBe(html2);
}); });
}); });
describe('Menu.toHtml XSS hardening (linkHoverColor into <style>)', () => {
test('a linkHoverColor value containing </style><script> is neutralized', () => {
const malicious = '#fff}</style><script>alert(1)</script><style>{';
const { html } = toHtml({ linkHoverColor: malicious }, '', 'node-xss');
expect(html).not.toContain('</style><script');
expect(html).not.toContain('<script>alert(1)</script>');
});
test('a normal linkHoverColor still renders in the hover rule', () => {
const { html } = toHtml({ linkHoverColor: '#ff0000' }, '', 'node-normal');
expect(html).toMatch(/:hover\s*\{\s*color:\s*#ff0000/);
});
});
+11 -7
View File
@@ -1,7 +1,7 @@
import React, { CSSProperties, useState } from 'react'; 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 { escapeHtml, escapeAttr, safeUrl, scopeId } from '../../utils/escape'; import { escapeHtml, escapeAttr, safeUrl, scopeId, cssValue } from '../../utils/escape';
/* ---------- Types ---------- */ /* ---------- Types ---------- */
@@ -125,14 +125,18 @@ Menu.craft = {
/* ---------- HTML export ---------- */ /* ---------- HTML export ---------- */
(Menu as any).toHtml = (props: MenuProps, _childrenHtml: string, nodeId?: string) => { (Menu as any).toHtml = (props: MenuProps, _childrenHtml: string, nodeId?: string) => {
const linkCol = props.linkColor || '#3f3f46'; // Sanitized once here -- linkCol/hoverCol/ctaBg/ctaText/gap/fSize are raw
const hoverCol = props.linkHoverColor || '#3b82f6'; // string-interpolation sinks below (hoverCol goes into a <style> block,
const ctaBg = props.ctaBgColor || '#3b82f6'; // the worst case: </style> breakout -> arbitrary <script>), see
const ctaText = props.ctaTextColor || '#ffffff'; // task-cssxss-brief.md.
const gap = props.gap || '24px'; const linkCol = cssValue(props.linkColor) || '#3f3f46';
const hoverCol = cssValue(props.linkHoverColor) || '#3b82f6';
const ctaBg = cssValue(props.ctaBgColor) || '#3b82f6';
const ctaText = cssValue(props.ctaTextColor) || '#ffffff';
const gap = cssValue(props.gap) || '24px';
const orientation = props.orientation || 'horizontal'; const orientation = props.orientation || 'horizontal';
const alignment = props.alignment || 'right'; const alignment = props.alignment || 'right';
const fSize = props.fontSize || '14px'; const fSize = cssValue(props.fontSize) || '14px';
const justifyMap: Record<string, string> = { left: 'flex-start', center: 'center', right: 'flex-end' }; const justifyMap: Record<string, string> = { left: 'flex-start', center: 'center', right: 'flex-end' };
@@ -5,24 +5,115 @@ 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>)', () => {
test('a hoverColor value containing </style><script> is neutralized in the hover <style> block', () => {
const malicious = '#fff}</style><script>alert(1)</script><style>{';
const { html } = toHtml({ hoverColor: malicious }, '');
expect(html).not.toContain('</style><script');
expect(html).not.toContain('<script>alert(1)</script>');
});
test('a backgroundColor value containing </style><script> is neutralized (mobile media-query rule)', () => {
const malicious = '#fff}</style><script>alert(2)</script><style>{';
const { html } = toHtml({ backgroundColor: malicious, showMobileMenu: true }, '');
expect(html).not.toContain('</style><script');
expect(html).not.toContain('<script>alert(2)</script>');
});
test('a ctaColor value containing </style><script> is neutralized', () => {
const malicious = '#fff}</style><script>alert(3)</script><style>{';
const { html } = toHtml({ ctaColor: malicious }, '');
expect(html).not.toContain('</style><script');
expect(html).not.toContain('<script>alert(3)</script>');
});
test('a textColor value containing a quote breakout does not escape the hamburger span style attribute', () => {
const malicious = '#333" onmouseover="alert(1)';
const { html } = toHtml({ textColor: malicious, showMobileMenu: true }, '');
expect(html).not.toMatch(/style="[^"]*"[^>]*onmouseover/);
});
test('normal colors still render correctly', () => {
const { html } = toHtml({ hoverColor: '#ff0000', backgroundColor: '#123456', ctaColor: '#00ff00' }, '');
expect(html).toMatch(/:hover\s*\{\s*color:\s*#ff0000/);
expect(html).toContain('background-color:#123456');
});
});
+36 -18
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 } from '../../utils/escape'; import { escapeHtml, escapeAttr, safeUrl, safeImageUrl, cssValue, scopeId } from '../../utils/escape';
/* ---------- Types ---------- */ /* ---------- Types ---------- */
@@ -210,17 +210,33 @@ Navbar.craft = {
/* ---------- HTML export ---------- */ /* ---------- HTML export ---------- */
(Navbar as any).toHtml = (props: NavbarProps, _childrenHtml: string) => { (Navbar as any).toHtml = (props: NavbarProps, _childrenHtml: string, nodeId?: string) => {
const bgColor = props.backgroundColor || '#ffffff'; // Sanitized once here -- these are raw string-interpolation sinks below
const textCol = props.textColor || '#3f3f46'; // (hoverCol/bgColor go into a <style> block, the worst case: </style>
const hoverCol = props.hoverColor || '#3b82f6'; // breakout -> arbitrary <script>), see task-cssxss-brief.md.
const ctaCol = props.ctaColor || '#3b82f6'; const bgColor = cssValue(props.backgroundColor) || '#ffffff';
const ctaTextCol = props.ctaTextColor || '#ffffff'; const textCol = cssValue(props.textColor) || '#3f3f46';
const pad = props.padding || '16px 24px'; const hoverCol = cssValue(props.hoverColor) || '#3b82f6';
const ctaCol = cssValue(props.ctaColor) || '#3b82f6';
const ctaTextCol = cssValue(props.ctaTextColor) || '#ffffff';
const pad = cssValue(props.padding) || '16px 24px';
const alignment = props.navAlignment || 'space-between'; const alignment = props.navAlignment || 'space-between';
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',
@@ -236,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',
@@ -269,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>`;
@@ -306,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>`,
@@ -11,3 +11,22 @@ describe('SearchBar.toHtml decorative icons (F2.5)', () => {
icons.forEach((tag: string) => expect(tag).toContain('aria-hidden="true"')); icons.forEach((tag: string) => expect(tag).toContain('aria-hidden="true"'));
}); });
}); });
describe('SearchBar.toHtml XSS hardening (placeholder/buttonText/showButton)', () => {
test('a placeholder value with an attribute-breakout string cannot escape placeholder=""', () => {
const malicious = 'Search..." onmouseover="alert(1)';
const { html } = toHtml({ placeholder: malicious }, '');
expect(html).not.toMatch(/"\s+onmouseover="/);
});
test('a buttonText value with a script tag is escaped as text content, not raw HTML', () => {
const malicious = '<script>alert(1)</script>';
const { html } = toHtml({ buttonText: malicious, showButton: true }, '');
expect(html).not.toContain('<script>alert(1)</script>');
});
test('a non-boolean showButton (string "false") still yields fixed, safe border-radius values', () => {
const { html } = toHtml({ showButton: 'false' as any }, '');
expect(html).toMatch(/border-radius:(8px 0 0 8px|8px)/);
});
});
@@ -14,3 +14,40 @@ describe('SocialLinks.toHtml accessibility (F2.5)', () => {
expect(html).toMatch(/<i class="fa fa-twitter"[^>]*aria-hidden="true"/); expect(html).toMatch(/<i class="fa fa-twitter"[^>]*aria-hidden="true"/);
}); });
}); });
describe('SocialLinks.toHtml XSS hardening (iconSize/iconColor/iconBgColor/gap into style=)', () => {
test('an iconSize value with an attribute-breakout string cannot escape style=""', () => {
const malicious = '20px" onmouseover="alert(1)';
const { html } = toHtml({ links: [{ platform: 'facebook', url: '#' }], iconSize: malicious as any }, '');
expect(html).not.toMatch(/"\s+onmouseover="/);
});
test('an iconColor value with an attribute-breakout string cannot escape style=""', () => {
const malicious = '#fff" onmouseover="alert(1)';
const { html } = toHtml({ links: [{ platform: 'facebook', url: '#' }], iconColor: malicious as any }, '');
expect(html).not.toMatch(/"\s+onmouseover="/);
});
test('an iconBgColor value with an attribute-breakout string cannot escape style=""', () => {
const malicious = '#374151" onmouseover="alert(1)';
const { html } = toHtml({ links: [{ platform: 'facebook', url: '#' }], iconShape: 'circle', iconBgColor: malicious as any }, '');
expect(html).not.toMatch(/"\s+onmouseover="/);
});
test('a gap value with an attribute-breakout string cannot escape the wrapper style=""', () => {
const malicious = '10px" onmouseover="alert(1)';
const { html } = toHtml({ links: [{ platform: 'facebook', url: '#' }], gap: malicious as any }, '');
expect(html).not.toMatch(/"\s+onmouseover="/);
});
test('a malicious platform key does not produce a raw class-attribute breakout', () => {
const malicious = 'x"><script>alert(1)</script>';
const { html } = toHtml({ links: [{ platform: malicious, url: '#' }] }, '');
expect(html).not.toContain('<script>alert(1)</script>');
});
test('a link url with a javascript: scheme is neutralized', () => {
const { html } = toHtml({ links: [{ platform: 'facebook', url: 'javascript:alert(1)' }] }, '');
expect(html).not.toContain('javascript:alert(1)');
});
});
+5 -4
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 { escapeAttr, safeUrl } from '../../utils/escape'; import { escapeAttr, safeUrl, cssValue } from '../../utils/escape';
interface SocialLink { interface SocialLink {
platform: string; platform: string;
@@ -163,9 +163,10 @@ SocialLinks.craft = {
(SocialLinks as any).toHtml = (props: SocialLinksProps, _childrenHtml: string) => { (SocialLinks as any).toHtml = (props: SocialLinksProps, _childrenHtml: string) => {
const links = props.links || defaultLinks; const links = props.links || defaultLinks;
const iconSize = props.iconSize || '20px'; // Sanitized -- raw string-interpolation sinks in aStyle/getShapeStr below.
const iconColor = props.iconColor || '#ffffff'; const iconSize = cssValue(props.iconSize) || '20px';
const iconBgColor = props.iconBgColor || '#374151'; const iconColor = cssValue(props.iconColor) || '#ffffff';
const iconBgColor = cssValue(props.iconBgColor) || '#374151';
const iconShape = props.iconShape || 'circle'; const iconShape = props.iconShape || 'circle';
const gap = props.gap || '10px'; const gap = props.gap || '10px';
const alignment = props.alignment || 'center'; const alignment = props.alignment || 'center';
@@ -0,0 +1,26 @@
import { describe, test, expect } from 'vitest';
import { Spacer } from './Spacer';
const toHtml = (Spacer as any).toHtml;
describe('Spacer.toHtml normal rendering', () => {
test('renders height into the style attribute', () => {
const { html } = toHtml({ height: '80px' }, '');
expect(html).toContain('height:80px');
});
});
describe('Spacer.toHtml XSS hardening (height into style=)', () => {
test('a height value with an attribute-breakout string cannot escape style=""', () => {
const malicious = '40px" onmouseover="alert(1)';
const { html } = toHtml({ height: malicious as any }, '');
expect(html).not.toMatch(/"\s+onmouseover="/);
expect(html).not.toMatch(/style="[^"]*"[^>]*onmouseover/);
});
test('a height value with a </style><script> breakout is neutralized', () => {
const malicious = '40px</style><script>alert(1)</script>';
const { html } = toHtml({ height: malicious as any }, '');
expect(html).not.toContain('<script>alert(1)</script>');
});
});
@@ -21,3 +21,57 @@ describe('StarRating.toHtml accessibility (F2.2)', () => {
expect(html).toContain('aria-label="Rating: 2 out of 10"'); expect(html).toContain('aria-label="Rating: 2 out of 10"');
}); });
}); });
describe('StarRating.toHtml XSS hardening (filledColor/emptyColor/size into style=)', () => {
test('a filledColor value containing a quote breakout is neutralized', () => {
const malicious = '#f00" onmouseover="alert(1)';
const { html } = toHtml({ rating: 3, maxStars: 5, filledColor: malicious }, '');
expect(html).not.toMatch(/style="[^"]*"[^>]*onmouseover/);
});
test('a size value containing </style><script> is neutralized', () => {
const malicious = '24px</style><script>alert(1)</script>';
const { html } = toHtml({ rating: 3, maxStars: 5, size: malicious }, '');
expect(html).not.toContain('<script>alert(1)</script>');
});
test('a normal filled color still renders', () => {
const { html } = toHtml({ rating: 5, maxStars: 5, filledColor: '#ff9900' }, '');
expect(html).toContain('color:#ff9900');
});
});
describe('StarRating.toHtml XSS hardening (rating/maxStars into aria-label, F2.2 CONFIRMED sink)', () => {
test('a maxStars value with an attribute-breakout string is neutralized in aria-label', () => {
const malicious = '5" onmouseover="alert(1)';
const { html } = toHtml({ rating: 3, maxStars: malicious as any }, '');
expect(html).not.toMatch(/onmouseover/);
expect(html).not.toMatch(/aria-label="Rating: 3 out of 5" onmouseover/);
});
test('a rating value with an attribute-breakout string is neutralized in aria-label', () => {
const malicious = '4.5" onmouseover="alert(1)';
const { html } = toHtml({ rating: malicious as any, maxStars: 5 }, '');
expect(html).not.toMatch(/onmouseover/);
});
test('a non-numeric maxStars does not blow up the star loop (no NaN glyph count, no huge output)', () => {
const malicious = '5" onmouseover="alert(1)';
const { html } = toHtml({ rating: 3, maxStars: malicious as any }, '');
const glyphs = html.match(/<i class="fa fa-star"/g) || [];
// Falls back to a sane default star count rather than looping 0 or NaN times.
expect(glyphs.length).toBeGreaterThan(0);
expect(glyphs.length).toBeLessThanOrEqual(50);
});
test('an absurdly large maxStars is clamped to a sane maximum instead of looping unboundedly', () => {
const { html } = toHtml({ rating: 3, maxStars: 1e9 as any }, '');
const glyphs = html.match(/<i class="fa fa-star"/g) || [];
expect(glyphs.length).toBeLessThanOrEqual(50);
});
test('normal numeric rating/maxStars still render the expected aria-label', () => {
const { html } = toHtml({ rating: 4.5, maxStars: 5 }, '');
expect(html).toMatch(/<span role="img" aria-label="Rating: 4\.5 out of 5"/);
});
});
+24 -6
View File
@@ -1,6 +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 { cssValue, escapeAttr } from '../../utils/escape';
interface StarRatingProps { interface StarRatingProps {
rating?: number; rating?: number;
@@ -97,11 +98,23 @@ StarRating.craft = {
/* ---------- HTML export ---------- */ /* ---------- HTML export ---------- */
(StarRating as any).toHtml = (props: StarRatingProps, _childrenHtml: string) => { (StarRating as any).toHtml = (props: StarRatingProps, _childrenHtml: string) => {
const rating = props.rating ?? 4.5; // `rating`/`maxStars` are declared `number` in TS but arrive unchecked at
const maxStars = props.maxStars || 5; // runtime (AI update_props only validates node_id; deserialized saved
const size = props.size || '24px'; // state is untyped JSON) -- a string like `5" onmouseover="alert(1)`
const filledColor = props.filledColor || '#f59e0b'; // breaks out of the aria-label attribute below, and an uncoerced/unclamped
const emptyColor = props.emptyColor || '#d1d5db'; // maxStars can also blow up the star-glyph loop (NaN, absurd loop count,
// or -- observed -- a RangeError from string concatenation overflow with
// e.g. maxStars=1e9). Coerce to numbers with sane fallbacks/clamps first.
const ratingRaw = Number(props.rating);
const rating = Number.isFinite(ratingRaw) ? ratingRaw : 4.5;
const maxStarsRaw = Number(props.maxStars);
const maxStars = Number.isFinite(maxStarsRaw)
? Math.min(Math.max(Math.trunc(maxStarsRaw), 0), 50)
: 5;
// Sanitized -- raw string-interpolation sinks in the star glyphs below.
const size = cssValue(props.size) || '24px';
const filledColor = cssValue(props.filledColor) || '#f59e0b';
const emptyColor = cssValue(props.emptyColor) || '#d1d5db';
const wrapperStyle = cssPropsToString({ const wrapperStyle = cssPropsToString({
display: 'inline-flex', display: 'inline-flex',
alignItems: 'center', alignItems: 'center',
@@ -123,7 +136,12 @@ StarRating.craft = {
// The star glyphs convey nothing to assistive tech on their own -- wrap // The star glyphs convey nothing to assistive tech on their own -- wrap
// in role="img" with a textual equivalent, and hide the decorative glyphs // in role="img" with a textual equivalent, and hide the decorative glyphs
// themselves (aria-hidden above) so AT doesn't announce each icon. // themselves (aria-hidden above) so AT doesn't announce each icon.
// Belt-and-suspenders: rating/maxStars are already coerced to numbers
// above, but the assembled label is still run through escapeAttr() in
// case a decimal/negative/Infinity edge case produces odd (though no
// longer dangerous) text.
const ariaLabel = escapeAttr(`Rating: ${rating} out of ${maxStars}`);
return { return {
html: `<span role="img" aria-label="Rating: ${rating} out of ${maxStars}"${wrapperStyle ? ` style="${wrapperStyle}"` : ''}>${starsHtml}</span>`, html: `<span role="img" aria-label="${ariaLabel}"${wrapperStyle ? ` style="${wrapperStyle}"` : ''}>${starsHtml}</span>`,
}; };
}; };
@@ -0,0 +1,22 @@
import { describe, test, expect } from 'vitest';
import { TextBlock } from './TextBlock';
const toHtml = (TextBlock as any).toHtml;
describe('TextBlock.toHtml text escaping (attacker-controlled `text` prop)', () => {
test('a tag-breakout attempt in text is neutralized (no injected element)', () => {
const { html } = toHtml({ text: '</p><img src=x onerror=alert(1)>' }, '');
expect(html).not.toContain('<img');
expect(html).toContain('&lt;img');
});
test('ampersand is escaped for well-formed text content (consistency with escapeHtml)', () => {
const { html } = toHtml({ text: 'Tom & Jerry' }, '');
expect(html).toContain('Tom &amp; Jerry');
});
test('a normal text value still renders unchanged', () => {
const { html } = toHtml({ text: 'Hello world' }, '');
expect(html).toBe('<p>Hello world</p>');
});
});
+2 -1
View File
@@ -1,6 +1,7 @@
import React, { CSSProperties, useCallback, useRef, useEffect } from 'react'; import React, { CSSProperties, useCallback, useRef, useEffect } 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 } from '../../utils/escape';
interface TextBlockProps { interface TextBlockProps {
text?: string; text?: string;
@@ -95,6 +96,6 @@ TextBlock.craft = {
(TextBlock as any).toHtml = (props: TextBlockProps, _childrenHtml: string) => { (TextBlock as any).toHtml = (props: TextBlockProps, _childrenHtml: string) => {
const styleStr = cssPropsToString(props.style); const styleStr = cssPropsToString(props.style);
const escapedText = (props.text || '').replace(/</g, '&lt;').replace(/>/g, '&gt;'); const escapedText = escapeHtml(props.text || '');
return { html: `<p${styleStr ? ` style="${styleStr}"` : ''}>${escapedText}</p>` }; return { html: `<p${styleStr ? ` style="${styleStr}"` : ''}>${escapedText}</p>` };
}; };
@@ -110,3 +110,19 @@ describe('ContactForm.toHtml accessibility (F2.1)', () => {
expect(ids1).toEqual(ids2); expect(ids1).toEqual(ids2);
}); });
}); });
describe('ContactForm.toHtml field type attribute sanitization', () => {
test('malicious field.type cannot break out of the input attribute; falls back to type="text"', () => {
const fields = [{ type: 'text"><img src=x onerror=alert(1)>' as any, label: 'Name', name: 'name', placeholder: 'Your name', required: false }];
const { html } = toHtml({ fields }, '');
expect(html).not.toContain('<img');
expect(html).not.toContain('onerror=');
expect(html).toContain('type="text"');
});
test('legitimate email field type still passes through unchanged', () => {
const fields = [{ type: 'email' as const, label: 'Email', name: 'email', placeholder: 'you@example.com', required: false }];
const { html } = toHtml({ fields }, '');
expect(html).toContain('type="email"');
});
});
+7 -5
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 { relayFormWiring } from '../../utils/form-relay-wiring'; import { relayFormWiring } from '../../utils/form-relay-wiring';
import { escapeHtml, escapeAttr, slugId } from '../../utils/escape'; import { escapeHtml, escapeAttr, slugId, cssValue, sanitizeInputType } from '../../utils/escape';
interface ContactFormField { interface ContactFormField {
type: 'text' | 'email' | 'tel' | 'textarea' | 'select'; type: 'text' | 'email' | 'tel' | 'textarea' | 'select';
@@ -175,9 +175,11 @@ ContactForm.craft = {
gap: '20px', gap: '20px',
...props.style, ...props.style,
}); });
const labelColor = props.labelColor || '#374151'; // Sanitized -- raw string-interpolation sinks in inputStyleStr/labelHtml
const inputBg = props.inputBg || '#ffffff'; // below.
const inputBorder = props.inputBorder || '#d1d5db'; const labelColor = cssValue(props.labelColor) || '#374151';
const inputBg = cssValue(props.inputBg) || '#ffffff';
const inputBorder = cssValue(props.inputBorder) || '#d1d5db';
const inputStyleStr = `width:100%;padding:10px 14px;font-size:14px;font-family:Inter,sans-serif;border:1px solid ${inputBorder};border-radius:6px;background-color:${inputBg};color:#1f2937;box-sizing:border-box;outline:none`; const inputStyleStr = `width:100%;padding:10px 14px;font-size:14px;font-family:Inter,sans-serif;border:1px solid ${inputBorder};border-radius:6px;background-color:${inputBg};color:#1f2937;box-sizing:border-box;outline:none`;
const fieldsHtml = (props.fields || defaultFields).map((field, i) => { const fieldsHtml = (props.fields || defaultFields).map((field, i) => {
@@ -194,7 +196,7 @@ ContactForm.craft = {
const opts = (field.options || []).map((o) => `<option value="${escapeAttr(o)}">${escapeHtml(o)}</option>`).join(''); const opts = (field.options || []).map((o) => `<option value="${escapeAttr(o)}">${escapeHtml(o)}</option>`).join('');
inputHtml = `<select id="${escapeAttr(fieldId)}" name="${escapeAttr(field.name)}" style="${inputStyleStr};cursor:pointer"${reqAttr}><option value="">${escapeHtml(field.placeholder || 'Select...')}</option>${opts}</select>`; inputHtml = `<select id="${escapeAttr(fieldId)}" name="${escapeAttr(field.name)}" style="${inputStyleStr};cursor:pointer"${reqAttr}><option value="">${escapeHtml(field.placeholder || 'Select...')}</option>${opts}</select>`;
} else { } else {
inputHtml = `<input id="${escapeAttr(fieldId)}" type="${field.type}" name="${escapeAttr(field.name)}" placeholder="${escapeAttr(field.placeholder)}" style="${inputStyleStr}"${reqAttr} />`; inputHtml = `<input id="${escapeAttr(fieldId)}" type="${sanitizeInputType(field.type)}" name="${escapeAttr(field.name)}" placeholder="${escapeAttr(field.placeholder)}" style="${inputStyleStr}"${reqAttr} />`;
} }
return `<div style="display:flex;flex-direction:column;gap:6px">${labelHtml}${inputHtml}</div>`; return `<div style="display:flex;flex-direction:column;gap:6px">${labelHtml}${inputHtml}</div>`;
}).join('\n '); }).join('\n ');
@@ -0,0 +1,25 @@
import { describe, test, expect } from 'vitest';
import { FormButton } from './FormButton';
const toHtml = (FormButton as any).toHtml;
describe('FormButton.toHtml', () => {
test('normal text renders as-is', () => {
const { html } = toHtml({ text: 'Send it' }, '');
expect(html).toContain('>Send it<');
expect(html).toContain('type="submit"');
});
test('type="submit" is a hardcoded literal, not prop-driven', () => {
const { html } = toHtml({ text: 'Submit' }, '');
expect(html).toMatch(/<button type="submit"/);
});
test('text content is escaped for <, >, &, and " (consistent with escapeHtml)', () => {
const { html } = toHtml({ text: '<script>alert(1)</script> & "quoted"' }, '');
expect(html).not.toContain('<script>');
expect(html).toContain('&lt;script&gt;alert(1)&lt;/script&gt;');
expect(html).toContain('&amp;');
expect(html).toContain('&quot;quoted&quot;');
});
});
+2 -1
View File
@@ -1,6 +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 } from '../../utils/escape';
interface FormButtonProps { interface FormButtonProps {
text?: string; text?: string;
@@ -74,7 +75,7 @@ FormButton.craft = {
cursor: 'pointer', cursor: 'pointer',
...props.style, ...props.style,
}); });
const escapedText = (props.text || 'Submit').replace(/</g, '&lt;').replace(/>/g, '&gt;'); const escapedText = escapeHtml(props.text || 'Submit');
return { return {
html: `<button type="submit"${styleStr ? ` style="${styleStr}"` : ''}>${escapedText}</button>`, html: `<button type="submit"${styleStr ? ` style="${styleStr}"` : ''}>${escapedText}</button>`,
}; };
@@ -39,3 +39,16 @@ describe('FormContainer.toHtml relay wiring', () => {
expect(mid1).not.toBe(mid2); expect(mid1).not.toBe(mid2);
}); });
}); });
describe('FormContainer.toHtml method attribute sanitization', () => {
test('malicious method value cannot break out of the attribute; falls back to POST', () => {
const { html } = toHtml({ action: '/legacy', method: 'POST"><script>alert(1)</script>' }, '');
expect(html).not.toContain('<script');
expect(html).toContain('method="POST"');
});
test('legitimate GET method still passes through unchanged (non-relay path)', () => {
const { html } = toHtml({ action: '/legacy', method: 'GET' }, '');
expect(html).toContain('method="GET"');
});
});
+2 -1
View File
@@ -3,6 +3,7 @@ import { useNode, Element, UserComponent } from '@craftjs/core';
import { Container } from '../layout/Container'; import { Container } from '../layout/Container';
import { cssPropsToString } from '../../utils/style-helpers'; import { cssPropsToString } from '../../utils/style-helpers';
import { relayFormWiring } from '../../utils/form-relay-wiring'; import { relayFormWiring } from '../../utils/form-relay-wiring';
import { sanitizeFormMethod } from '../../utils/escape';
interface FormContainerProps { interface FormContainerProps {
action?: string; action?: string;
@@ -74,7 +75,7 @@ FormContainer.craft = {
...props.style, ...props.style,
}); });
const { useRelay, marker, actionAttr, honeypot } = relayFormWiring(props.recipientEmail, props.thankYouUrl, props.action, nodeId); const { useRelay, marker, actionAttr, honeypot } = relayFormWiring(props.recipientEmail, props.thankYouUrl, props.action, nodeId);
const method = useRelay ? 'POST' : (props.method || 'POST'); // relay requires POST const method = useRelay ? 'POST' : sanitizeFormMethod(props.method); // relay requires POST
const body = honeypot + childrenHtml; // honeypot as first child const body = honeypot + childrenHtml; // honeypot as first child
return { return {
html: `${marker}<form action="${actionAttr}" method="${method}"${styleStr ? ` style="${styleStr}"` : ''}>${body}</form>`, html: `${marker}<form action="${actionAttr}" method="${method}"${styleStr ? ` style="${styleStr}"` : ''}>${body}</form>`,
@@ -58,3 +58,17 @@ describe('InputField.toHtml deterministic + unique ids (thread node id, resolves
expect(id1).toBe(id2); expect(id1).toBe(id2);
}); });
}); });
describe('InputField.toHtml type attribute sanitization', () => {
test('malicious type value cannot break out of the attribute; falls back to type="text"', () => {
const { html } = toHtml({ label: 'Name', name: 'name', type: 'text" autofocus onfocus="alert(1)' as any }, '');
expect(html).not.toContain('onfocus=');
expect(html).not.toContain('autofocus');
expect(html).toContain('type="text"');
});
test('legitimate number type still passes through unchanged', () => {
const { html } = toHtml({ label: 'Age', name: 'age', type: 'number' as const }, '');
expect(html).toContain('type="number"');
});
});
+2 -2
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, scopeId } from '../../utils/escape'; import { escapeHtml, escapeAttr, scopeId, sanitizeInputType } from '../../utils/escape';
interface InputFieldProps { interface InputFieldProps {
label?: string; label?: string;
@@ -111,7 +111,7 @@ InputField.craft = {
return { return {
html: `<div${wrapStyle ? ` style="${wrapStyle}"` : ''}> html: `<div${wrapStyle ? ` style="${wrapStyle}"` : ''}>
${labelHtml} ${labelHtml}
<input id="${escapeAttr(fieldId)}" type="${props.type || 'text'}" name="${escapeAttr(props.name || 'field')}" placeholder="${escapeAttr(props.placeholder || '')}"${reqAttr}${ariaLabelAttr} style="padding:10px 12px;border:1px solid #d4d4d8;border-radius:6px;font-size:14px;color:#18181b;background-color:#ffffff;width:100%;box-sizing:border-box" /> <input id="${escapeAttr(fieldId)}" type="${sanitizeInputType(props.type)}" name="${escapeAttr(props.name || 'field')}" placeholder="${escapeAttr(props.placeholder || '')}"${reqAttr}${ariaLabelAttr} style="padding:10px 12px;border:1px solid #d4d4d8;border-radius:6px;font-size:14px;color:#18181b;background-color:#ffffff;width:100%;box-sizing:border-box" />
</div>`, </div>`,
}; };
}; };
@@ -0,0 +1,39 @@
import { describe, test, expect } from 'vitest';
import { SubscribeForm } from './SubscribeForm';
const toHtml = (SubscribeForm as any).toHtml;
describe('SubscribeForm.toHtml hardcoded attributes stay hardcoded (no raw prop breakout)', () => {
test('form method is always POST regardless of any injected props', () => {
const { html } = toHtml({ heading: 'Join us', method: 'GET"><script>alert(1)</script>' } as any, '');
expect(html).toContain('<form method="POST"');
expect(html).not.toContain('<script');
});
test('email input type is always "email" regardless of any injected props', () => {
const { html } = toHtml({ type: 'text"><img src=x onerror=alert(1)>' } as any, '');
expect(html).toContain('<input type="email"');
expect(html).not.toContain('<img');
expect(html).not.toContain('onerror=');
});
test('layout enum only ever feeds one of two fixed literal style strings, never raw', () => {
const { html: inlineHtml } = toHtml({ layout: 'inline' }, '');
const { html: stackedHtml } = toHtml({ layout: 'stacked' }, '');
expect(inlineHtml).toContain('flex-direction:row');
expect(stackedHtml).toContain('flex-direction:column');
});
test('malicious layout value cannot inject raw CSS/attribute breakout (falls through the isInline boolean check to the stacked literal)', () => {
const { html } = toHtml({ layout: '"><script>alert(1)</script>' as any }, '');
expect(html).not.toContain('<script');
expect(html).toContain('flex-direction:column');
});
test('normal render still produces expected structure', () => {
const { html } = toHtml({ heading: 'Subscribe', placeholder: 'you@example.com', buttonText: 'Go' }, '');
expect(html).toContain('Subscribe');
expect(html).toContain('placeholder="you@example.com"');
expect(html).toContain('>Go<');
});
});
@@ -50,3 +50,16 @@ describe('TextareaField.toHtml deterministic + unique ids (thread node id, resol
expect(id1).toBe(id2); expect(id1).toBe(id2);
}); });
}); });
describe('TextareaField.toHtml rows attribute sanitization', () => {
test('malicious rows value cannot break out of the attribute; falls back to a numeric rows', () => {
const { html } = toHtml({ label: 'Message', name: 'message', rows: '4"><script>alert(1)</script>' as any }, '');
expect(html).not.toContain('<script');
expect(html).toMatch(/rows="\d+"/);
});
test('legitimate numeric rows still passes through unchanged', () => {
const { html } = toHtml({ label: 'Message', name: 'message', rows: 8 }, '');
expect(html).toContain('rows="8"');
});
});
+6 -1
View File
@@ -97,6 +97,11 @@ TextareaField.craft = {
...props.style, ...props.style,
}); });
const reqAttr = props.required ? ' required' : ''; const reqAttr = props.required ? ' required' : '';
// `rows` is declared as a TS `number` but arrives unchecked (AI update_props
// path only validates node_id; deserialized saved-state JSON is untyped at
// runtime), so a string like `4"><script>...` must be coerced to a real
// number before interpolation, not trusted as already-numeric.
const rows = Number(props.rows) || 4;
// Deterministic AND unique id: scoped on the Craft node id so the // Deterministic AND unique id: scoped on the Craft node id so the
// <label for> always matches the <textarea id> AND two TextareaField // <label for> always matches the <textarea id> AND two TextareaField
// instances that share the same (often default) `name` -- e.g. two // instances that share the same (often default) `name` -- e.g. two
@@ -113,7 +118,7 @@ TextareaField.craft = {
return { return {
html: `<div${wrapStyle ? ` style="${wrapStyle}"` : ''}> html: `<div${wrapStyle ? ` style="${wrapStyle}"` : ''}>
${labelHtml} ${labelHtml}
<textarea id="${escapeAttr(fieldId)}" name="${escapeAttr(props.name || 'message')}" placeholder="${escapeAttr(props.placeholder || '')}" rows="${props.rows || 4}"${reqAttr}${ariaLabelAttr} style="padding:10px 12px;border:1px solid #d4d4d8;border-radius:6px;font-size:14px;color:#18181b;background-color:#ffffff;width:100%;box-sizing:border-box;resize:vertical;font-family:inherit"></textarea> <textarea id="${escapeAttr(fieldId)}" name="${escapeAttr(props.name || 'message')}" placeholder="${escapeAttr(props.placeholder || '')}" rows="${escapeAttr(String(rows))}"${reqAttr}${ariaLabelAttr} style="padding:10px 12px;border:1px solid #d4d4d8;border-radius:6px;font-size:14px;color:#18181b;background-color:#ffffff;width:100%;box-sizing:border-box;resize:vertical;font-family:inherit"></textarea>
</div>`, </div>`,
}; };
}; };
@@ -0,0 +1,58 @@
import { describe, test, expect } from 'vitest';
import { BackgroundSection } from './BackgroundSection';
const toHtml = (BackgroundSection as any).toHtml;
describe('BackgroundSection.toHtml anchorId', () => {
test('escapes a malicious anchorId (attribute breakout attempt)', () => {
const { html } = toHtml({ anchorId: 'x" onmouseover="alert(1)' }, 'child');
expect(html).not.toContain('onmouseover="alert(1)"');
});
test('a normal anchorId still renders correctly', () => {
const { html } = toHtml({ anchorId: 'my-bg-section' }, 'child');
expect(html).toContain('id="my-bg-section"');
});
});
describe('BackgroundSection.toHtml style-value XSS hardening', () => {
test('a malicious bgImage cannot break out of the outer style attribute via url(...)', () => {
const malicious = 'javascript:alert(1)) foo{background:red}</style><script>alert(1)</script';
const { html } = toHtml({ bgImage: malicious }, 'child');
expect(html).not.toContain('<script>alert(1)</script>');
expect(html).not.toContain('javascript:alert(1)');
});
test('a malicious bgColor cannot break out of the outer style attribute', () => {
const malicious = 'red" onmouseover="alert(1)';
const { html } = toHtml({ bgColor: malicious }, 'child');
expect(html).not.toContain('onmouseover="alert(1)"');
});
test('a malicious overlayColor cannot break out of the overlay style attribute', () => {
const malicious = 'red" onmouseover="alert(1)';
const { html } = toHtml({ overlayColor: malicious }, 'child');
expect(html).not.toContain('onmouseover="alert(1)"');
});
test('a wrong-typed overlayOpacity (string, not number) cannot break out of the overlay style attribute', () => {
const malicious = '0.4" onmouseover="alert(1)' as any;
const { html } = toHtml({ overlayOpacity: malicious }, 'child');
expect(html).not.toContain('onmouseover="alert(1)"');
});
test('a malicious innerMaxWidth cannot break out of the inner style attribute', () => {
const malicious = '1200px" onmouseover="alert(1)';
const { html } = toHtml({ innerMaxWidth: malicious }, 'child');
expect(html).not.toContain('onmouseover="alert(1)"');
});
test('normal props still render correctly', () => {
const { html } = toHtml({ bgImage: 'https://example.com/bg.jpg', bgColor: '#1e293b', overlayColor: '#000000', overlayOpacity: 0.4, innerMaxWidth: '1200px' }, 'child');
expect(html).toContain("url('https://example.com/bg.jpg')");
expect(html).toContain('background-color:#1e293b');
expect(html).toContain('opacity:0.4');
expect(html).toContain('max-width:1200px');
expect(html).toContain('child');
});
});
@@ -59,3 +59,24 @@ describe('ColumnLayout.toHtml deterministic + unique scope ids (thread node id,
expect(html1).toBe(html2); expect(html1).toBe(html2);
}); });
}); });
describe('ColumnLayout.toHtml XSS hardening (gap into <style>)', () => {
test('a gap value containing </style><script> is neutralized in the <style>-context nth-child rule', () => {
const malicious = '0px)}</style><script>alert(1)</script><style>{';
const { html } = toHtml({ columns: 2, split: '50-50', gap: malicious }, '<div>A</div><div>B</div>', 'node-xss');
expect(html).not.toContain('</style><script');
expect(html).not.toContain('<script>alert(1)</script>');
});
test('a gap value containing a quote/semicolon breakout is neutralized in the style attribute', () => {
const malicious = '16px" onmouseover="alert(1)';
const { html } = toHtml({ columns: 2, split: '50-50', gap: malicious }, '', 'node-xss2');
expect(html).not.toMatch(/style="[^"]*"[^>]*onmouseover/);
});
test('a normal gap value still renders correctly', () => {
const { html } = toHtml({ columns: 2, split: '50-50', gap: '24px' }, '<div>A</div>', 'node-normal');
expect(html).toContain('gap:24px');
expect(html).toMatch(/calc\(50% - 24px\)/);
});
});
+6 -2
View File
@@ -2,7 +2,7 @@ import React, { CSSProperties } from 'react';
import { useNode, Element, UserComponent } from '@craftjs/core'; import { useNode, Element, UserComponent } from '@craftjs/core';
import { Container } from './Container'; import { Container } from './Container';
import { cssPropsToString } from '../../utils/style-helpers'; import { cssPropsToString } from '../../utils/style-helpers';
import { escapeAttr, scopeId } from '../../utils/escape'; import { escapeAttr, scopeId, cssValue } from '../../utils/escape';
type SplitOption = type SplitOption =
| '100' | '100'
@@ -117,7 +117,11 @@ ColumnLayout.craft = {
(ColumnLayout as any).toHtml = (props: ColumnLayoutProps, childrenHtml: string, nodeId?: string) => { (ColumnLayout as any).toHtml = (props: ColumnLayoutProps, childrenHtml: string, nodeId?: string) => {
const columns = props.columns || 2; const columns = props.columns || 2;
const split = props.split || '50-50'; const split = props.split || '50-50';
const gap = props.gap || '16px'; // Sanitized once here so BOTH the raw <style> nth-child rule below AND the
// cssPropsToString-built outerStyle get a safe value -- gap is a raw
// string-interpolation sink into a <style> block (worst case: </style>
// breakout -> arbitrary <script>), see task-cssxss-brief.md.
const gap = cssValue(props.gap) || '16px';
const widths = getWidths(split, columns); const widths = getWidths(split, columns);
const outerStyle = cssPropsToString({ const outerStyle = cssPropsToString({
@@ -33,3 +33,33 @@ describe('Container.toHtml cssId/cssClass', () => {
expect(html).toContain('id="anchor-id"'); expect(html).toContain('id="anchor-id"');
}); });
}); });
describe('Container.toHtml tag allowlist (adversarial re-review, same class as C1)', () => {
test('a malicious tag value falls back to div -- no injected <img>, no broken-out attrs', () => {
const { html } = toHtml({ tag: 'div><img src=x onerror=alert(1)' }, 'child');
expect(html).not.toContain('<img');
expect(html).not.toContain('onerror');
expect(html.startsWith('<div')).toBe(true);
expect(html.endsWith('</div>')).toBe(true);
});
test('a tag value outside the known-safe set falls back to div', () => {
const { html } = toHtml({ tag: 'script' }, 'child');
expect(html.startsWith('<div')).toBe(true);
expect(html).not.toContain('<script');
});
test('a valid tag (section) still emits <section', () => {
const { html } = toHtml({ tag: 'section' }, 'child');
expect(html).toContain('<section');
expect(html).toContain('</section>');
});
test('all other allowlisted tags still work', () => {
for (const tag of ['div', 'article', 'header', 'footer', 'main']) {
const { html } = toHtml({ tag }, 'child');
expect(html.startsWith(`<${tag}`)).toBe(true);
expect(html.endsWith(`</${tag}>`)).toBe(true);
}
});
});
+16 -2
View File
@@ -3,6 +3,19 @@ import { useNode, UserComponent } from '@craftjs/core';
import { cssPropsToString } from '../../utils/style-helpers'; import { cssPropsToString } from '../../utils/style-helpers';
import { escapeAttr } from '../../utils/escape'; import { escapeAttr } from '../../utils/escape';
// The only tag names Container actually supports (matches the TS union
// below and the `tag` default in `.craft.props`). `tag` is settable via the
// AI `update_props` path and from deserialized saved state -- neither is
// type-checked at runtime -- so a malicious value like
// `div><img src=x onerror=alert(1)` must never reach the `<${tag}` template
// position in `toHtml`/the live render. Anything not in this allowlist
// falls back to `'div'`.
const ALLOWED_CONTAINER_TAGS = ['div', 'section', 'article', 'header', 'footer', 'main'] as const;
export type ContainerTag = (typeof ALLOWED_CONTAINER_TAGS)[number];
export const sanitizeContainerTag = (tag: unknown): ContainerTag =>
(ALLOWED_CONTAINER_TAGS as readonly unknown[]).includes(tag) ? (tag as ContainerTag) : 'div';
interface ContainerProps { interface ContainerProps {
style?: CSSProperties; style?: CSSProperties;
tag?: 'div' | 'section' | 'article' | 'header' | 'footer' | 'main'; tag?: 'div' | 'section' | 'article' | 'header' | 'footer' | 'main';
@@ -42,6 +55,7 @@ export const Container: UserComponent<ContainerProps> = ({
}) => { }) => {
const { connectors: { connect, drag } } = useNode(); const { connectors: { connect, drag } } = useNode();
const safeTag = sanitizeContainerTag(tag);
const needsBoxedWrapper = contentWidth === 'boxed'; const needsBoxedWrapper = contentWidth === 'boxed';
const flexStyles = flexAlignFromTextAlign(style.textAlign); const flexStyles = flexAlignFromTextAlign(style.textAlign);
@@ -58,7 +72,7 @@ export const Container: UserComponent<ContainerProps> = ({
const idValue = cssId || anchorId || undefined; const idValue = cssId || anchorId || undefined;
const el = React.createElement( const el = React.createElement(
tag, safeTag,
{ {
ref: (ref: HTMLElement | null): void => { if (ref) connect(drag(ref)); }, ref: (ref: HTMLElement | null): void => { if (ref) connect(drag(ref)); },
style: outerStyle, style: outerStyle,
@@ -97,7 +111,7 @@ Container.craft = {
/* ---------- HTML export ---------- */ /* ---------- HTML export ---------- */
(Container as any).toHtml = (props: ContainerProps, childrenHtml: string) => { (Container as any).toHtml = (props: ContainerProps, childrenHtml: string) => {
const tag = props.tag || 'div'; const tag = sanitizeContainerTag(props.tag);
const isBoxed = props.contentWidth === 'boxed'; const isBoxed = props.contentWidth === 'boxed';
const flexStyles = flexAlignFromTextAlign(props.style?.textAlign); const flexStyles = flexAlignFromTextAlign(props.style?.textAlign);
@@ -0,0 +1,75 @@
import { describe, test, expect } from 'vitest';
import { Section } from './Section';
const toHtml = (Section as any).toHtml;
describe('Section.toHtml anchorId', () => {
test('escapes a malicious anchorId (attribute breakout attempt)', () => {
const { html } = toHtml({ anchorId: 'x" onmouseover="alert(1)' }, 'child');
expect(html).not.toContain('onmouseover="alert(1)"');
});
test('a normal anchorId still renders correctly', () => {
const { html } = toHtml({ anchorId: 'my-section' }, 'child');
expect(html).toContain('id="my-section"');
});
});
describe('Section.toHtml childrenHtml passthrough', () => {
test('children are preserved', () => {
const { html } = toHtml({}, '<p>hello</p>');
expect(html).toContain('<p>hello</p>');
});
});
describe('Section.toHtml shape divider color/height XSS hardening', () => {
test('a malicious topDividerColor cannot break out of the SVG style attribute', () => {
const malicious = 'red" onmouseover="alert(1)';
const { html } = toHtml({ topDivider: 'wave', topDividerColor: malicious }, '');
expect(html).not.toContain('onmouseover="alert(1)"');
});
test('a malicious topDividerColor cannot inject a </style><script> breakout', () => {
const malicious = 'red</style><script>alert(1)</script>';
const { html } = toHtml({ topDivider: 'wave', topDividerColor: malicious }, '');
expect(html).not.toContain('<script>alert(1)</script>');
});
test('a malicious bottomDividerHeight cannot break out of the wrapper style attribute', () => {
const malicious = '50px" onmouseover="alert(1)';
const { html } = toHtml({ bottomDivider: 'angle', bottomDividerHeight: malicious }, '');
expect(html).not.toContain('onmouseover="alert(1)"');
});
test('a normal divider color/height still renders correctly', () => {
const { html } = toHtml({ topDivider: 'wave', topDividerColor: '#123456', topDividerHeight: '80px' }, '');
expect(html).toContain('fill:#123456');
expect(html).toContain('height:80px');
});
test('divider shape "none" emits no divider markup', () => {
const { html } = toHtml({ topDivider: 'none' }, 'child');
expect(html).not.toContain('<svg');
});
test('an unrecognized divider shape value emits no divider markup and no injected content', () => {
const malicious = 'wave"><script>alert(1)</script>' as any;
const { html } = toHtml({ topDivider: malicious }, 'child');
expect(html).not.toContain('<script>alert(1)</script>');
expect(html).not.toContain('<svg');
});
test('a prototype-property-name divider shape (__proto__) does not leak [object Object]/function source into the SVG path', () => {
const { html } = toHtml({ topDivider: '__proto__' as any }, 'child');
expect(html).not.toContain('[object');
expect(html).not.toContain('native code');
expect(html).not.toContain('<svg');
});
test('a prototype-property-name divider shape (toString) does not leak Object.prototype.toString source into the SVG path', () => {
const { html } = toHtml({ topDivider: 'toString' as any }, 'child');
expect(html).not.toContain('[object');
expect(html).not.toContain('native code');
expect(html).not.toContain('<svg');
});
});
+15 -4
View File
@@ -2,7 +2,7 @@ import React, { CSSProperties } from 'react';
import { useNode, Element, UserComponent } from '@craftjs/core'; import { useNode, Element, UserComponent } from '@craftjs/core';
import { cssPropsToString } from '../../utils/style-helpers'; import { cssPropsToString } from '../../utils/style-helpers';
import { Container } from './Container'; import { Container } from './Container';
import { escapeAttr } from '../../utils/escape'; import { escapeAttr, cssValue } from '../../utils/escape';
/* ---------- Shape Divider SVG Paths ---------- */ /* ---------- Shape Divider SVG Paths ---------- */
@@ -38,7 +38,14 @@ const ShapeDivider: React.FC<{
position: 'top' | 'bottom'; position: 'top' | 'bottom';
}> = ({ shape, color, height, position }) => { }> = ({ shape, color, height, position }) => {
if (!shape || shape === 'none') return null; if (!shape || shape === 'none') return null;
const path = DIVIDER_PATHS[shape]; // `shape` is attacker-controlled (AI update_props / deserialized state) and
// not runtime-type-checked. A plain-object index lookup with a string key
// like '__proto__', 'toString', or 'constructor' returns an INHERITED
// Object.prototype value (not undefined), which would otherwise leak
// "[object Object]" / a function's source text into the SVG `d` attribute
// below. hasOwnProperty restricts the lookup to the real allowlisted keys.
if (!Object.prototype.hasOwnProperty.call(DIVIDER_PATHS, shape)) return null;
const path = DIVIDER_PATHS[shape as Exclude<DividerShape, 'none'>];
if (!path) return null; if (!path) return null;
const isTop = position === 'top'; const isTop = position === 'top';
@@ -162,12 +169,16 @@ function buildDividerHtml(
position: 'top' | 'bottom', position: 'top' | 'bottom',
): string { ): string {
if (!shape || shape === 'none') return ''; if (!shape || shape === 'none') return '';
const path = DIVIDER_PATHS[shape]; // See the matching hasOwnProperty guard in <ShapeDivider> above -- same
// prototype-pollution-shaped lookup, same fix.
if (!Object.prototype.hasOwnProperty.call(DIVIDER_PATHS, shape)) return '';
const path = DIVIDER_PATHS[shape as Exclude<DividerShape, 'none'>];
if (!path) return ''; if (!path) return '';
const isTop = position === 'top'; const isTop = position === 'top';
const h = height || '50px'; const h = height || '50px';
const c = color || '#ffffff'; // Sanitized -- raw string-interpolation sink in the SVG `fill:${c}` below.
const c = cssValue(color) || '#ffffff';
const wrapperStyle = cssPropsToString({ const wrapperStyle = cssPropsToString({
position: 'absolute', position: 'absolute',
@@ -0,0 +1,62 @@
import { describe, test, expect, vi, beforeEach } from 'vitest';
import React from 'react';
import { createRoot, Root } from 'react-dom/client';
import { act } from 'react-dom/test-utils';
/* ImageBlock only needs useNode from @craftjs/core. Mock it following the
DOM-harness pattern in src/components/basic/Footer.editguard.test.tsx (no
@testing-library/react in this repo) so we can render the real component
tree and inspect the emitted <img src> without a real <Editor>. */
vi.mock('@craftjs/core', () => ({
useNode: (collect?: (node: any) => any) => {
const node = { events: { selected: false } };
return {
connectors: { connect: (el: any) => el, drag: (el: any) => el },
actions: { setProp: vi.fn() },
...(collect ? collect(node) : {}),
};
},
}));
import { ImageBlock } from './ImageBlock';
let container: HTMLDivElement;
let root: Root;
function render(ui: React.ReactElement) {
container = document.createElement('div');
document.body.appendChild(container);
act(() => {
root = createRoot(container);
root.render(ui);
});
}
beforeEach(() => {
vi.clearAllMocks();
});
describe('ImageBlock render falls back to the placeholder for an explicit empty src (Bug 1)', () => {
test('src="" (explicit, overrides the default parameter) still renders a non-empty placeholder src', () => {
render(<ImageBlock src="" alt="Image" />);
const img = container.querySelector('img')!;
expect(img.getAttribute('src')).not.toBe('');
expect(img.getAttribute('src')).toMatch(/^data:image\/svg\+xml/);
container.remove();
});
test('src=undefined (default parameter path) still renders the placeholder (unchanged behavior)', () => {
render(<ImageBlock alt="Image" />);
const img = container.querySelector('img')!;
expect(img.getAttribute('src')).not.toBe('');
expect(img.getAttribute('src')).toMatch(/^data:image\/svg\+xml/);
container.remove();
});
test('a real src is rendered unchanged', () => {
render(<ImageBlock src="https://example.com/photo.jpg" alt="A photo" />);
const img = container.querySelector('img')!;
expect(img.getAttribute('src')).toBe('https://example.com/photo.jpg');
container.remove();
});
});
@@ -0,0 +1,34 @@
import { describe, test, expect } from 'vitest';
import { ImageBlock } from './ImageBlock';
const toHtml = (ImageBlock as any).toHtml;
describe('ImageBlock.toHtml src/alt XSS hardening', () => {
test('a javascript: src never reaches the output', () => {
const { html } = toHtml({ src: 'javascript:alert(1)' }, '');
expect(html).not.toContain('javascript:');
});
test('a malicious src cannot break out of the src attribute', () => {
const malicious = 'https://example.com/x.jpg" onerror="alert(1)';
const { html } = toHtml({ src: malicious }, '');
expect(html).not.toContain('onerror="alert(1)"');
});
test('a malicious alt cannot break out of the alt attribute', () => {
const malicious = 'x" onerror="alert(1)';
const { html } = toHtml({ src: 'https://example.com/x.jpg', alt: malicious }, '');
expect(html).not.toContain('onerror="alert(1)"');
});
test('a placeholder/empty src emits no output', () => {
const { html } = toHtml({ src: '' }, '');
expect(html).toBe('');
});
test('a normal image still renders correctly', () => {
const { html } = toHtml({ src: 'https://example.com/photo.jpg', alt: 'A photo' }, '');
expect(html).toContain('src="https://example.com/photo.jpg"');
expect(html).toContain('alt="A photo"');
});
});
+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}"` : ''} />` };
}; };
@@ -27,3 +27,41 @@ describe('MapEmbed.toHtml iframe src ampersand encoding (F-export review Minor)'
expect(srcMatch![1]).not.toMatch(/&(?!amp;)/); expect(srcMatch![1]).not.toMatch(/&(?!amp;)/);
}); });
}); });
describe('MapEmbed.toHtml address/zoom/height XSS hardening', () => {
test('a malicious address cannot break out of the src or title attribute', () => {
const malicious = 'X" onerror="alert(1)';
const { html } = toHtml({ address: malicious }, '');
expect(html).not.toContain('onerror="alert(1)"');
});
test('a wrong-typed zoom (string with attribute-breakout chars) cannot break out of the src attribute', () => {
const malicious = '14"><script>alert(1)</script>' as any;
const { html } = toHtml({ address: 'X', zoom: malicious }, '');
expect(html).not.toContain('<script>alert(1)</script>');
expect(html).not.toContain('"><script');
});
test('a wrong-typed zoom is coerced to a safe numeric value in the exported URL (defense in depth beyond escaping)', () => {
const malicious = '14"><script>alert(1)</script>' as any;
const { html } = toHtml({ address: 'X', zoom: malicious }, '');
const srcMatch = html.match(/<iframe src="([^"]+)"/);
expect(srcMatch).toBeTruthy();
// Decode the entity-escaped src back to a plain string and confirm the
// `z=` param is a bare, well-formed number -- not the raw attacker string.
const decoded = srcMatch![1].replace(/&amp;/g, '&').replace(/&quot;/g, '"').replace(/&lt;/g, '<').replace(/&gt;/g, '>');
expect(decoded).toMatch(/[&?]z=\d+(&|$)/);
});
test('a malicious height cannot break out of the iframe style attribute', () => {
const malicious = '400px" onmouseover="alert(1)';
const { html } = toHtml({ address: 'X', height: malicious }, '');
expect(html).not.toContain('onmouseover="alert(1)"');
});
test('a normal zoom/height still renders correctly', () => {
const { html } = toHtml({ address: 'X', zoom: 10, height: '300px' }, '');
expect(html).toContain('z=10');
expect(html).toContain('height:300px');
});
});
+9 -1
View File
@@ -12,7 +12,15 @@ interface MapEmbedProps {
function buildMapUrl(address: string, zoom: number): string { function buildMapUrl(address: string, zoom: number): string {
const encoded = encodeURIComponent(address); const encoded = encodeURIComponent(address);
return `https://maps.google.com/maps?q=${encoded}&z=${zoom}&output=embed`; // `zoom` is declared as `number` but is not runtime-type-checked (AI
// update_props / deserialized state can hand us anything). The final src
// string is still run through escapeAttr(safeUrl(...)) at the toHtml call
// site, which already blocks attribute-breakout -- but Number-coercing
// here too keeps the emitted URL a well-formed `z=<digits>` query param
// instead of smuggling arbitrary attacker text into it.
const z = Number(zoom);
const safeZoom = Number.isFinite(z) ? z : 14;
return `https://maps.google.com/maps?q=${encoded}&z=${safeZoom}&output=embed`;
} }
export const MapEmbed: UserComponent<MapEmbedProps> = ({ export const MapEmbed: UserComponent<MapEmbedProps> = ({
@@ -72,6 +72,38 @@ describe('VideoBlock.toHtml iframe accessibility (F2.4)', () => {
}); });
}); });
describe('VideoBlock.toHtml overlay/innerMaxWidth XSS hardening (background mode)', () => {
test('a malicious overlayColor cannot break out of the overlay style attribute', () => {
const malicious = 'red" onmouseover="alert(1)';
const { html } = toHtml({ videoUrl: 'https://vimeo.com/123456789', isBackground: true, overlayColor: malicious }, '');
expect(html).not.toContain('onmouseover="alert(1)"');
});
test('a wrong-typed overlayOpacity (string, not number) cannot break out of the overlay style attribute', () => {
const malicious = '50" onmouseover="alert(1)' as any;
const { html } = toHtml({ videoUrl: 'https://vimeo.com/123456789', isBackground: true, overlayOpacity: malicious }, '');
expect(html).not.toContain('onmouseover="alert(1)"');
});
test('a malicious innerMaxWidth cannot break out of the inner style attribute', () => {
const malicious = '1200px" onmouseover="alert(1)';
const { html } = toHtml({ videoUrl: 'https://vimeo.com/123456789', isBackground: true, innerMaxWidth: malicious }, '');
expect(html).not.toContain('onmouseover="alert(1)"');
});
test('a malicious style.borderRadius cannot break out of the style attribute (normal mode, iframe wrapper)', () => {
const malicious = { borderRadius: '8px" onmouseover="alert(1)' } as any;
const { html } = toHtml({ videoUrl: 'https://vimeo.com/123456789', style: malicious }, '');
expect(html).not.toContain('onmouseover="alert(1)"');
});
test('a malicious style.borderRadius cannot break out of the style attribute (direct file <video>)', () => {
const malicious = { borderRadius: '8px" onmouseover="alert(1)' } as any;
const { html } = toHtml({ videoUrl: 'https://example.com/clip.mp4', style: malicious }, '');
expect(html).not.toContain('onmouseover="alert(1)"');
});
});
describe('VideoBlock.toHtml iframe src ampersand encoding (F-export review Minor)', () => { describe('VideoBlock.toHtml iframe src ampersand encoding (F-export review Minor)', () => {
test('embed params joined with literal & are HTML-entity-encoded in the emitted src attribute', () => { test('embed params joined with literal & are HTML-entity-encoded in the emitted src attribute', () => {
// autoplay+muted+controls=false forces buildEmbedParams to concatenate // autoplay+muted+controls=false forces buildEmbedParams to concatenate
+7 -5
View File
@@ -1,7 +1,7 @@
import React, { CSSProperties, useState } from 'react'; 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 { escapeHtml, escapeAttr } from '../../utils/escape'; import { escapeHtml, escapeAttr, cssValue } from '../../utils/escape';
interface AccordionItem { interface AccordionItem {
title: string; title: string;
@@ -151,10 +151,12 @@ Accordion.craft = {
...props.style, ...props.style,
}); });
const idAttr = props.anchorId ? ` id="${escapeAttr(props.anchorId)}"` : ''; const idAttr = props.anchorId ? ` id="${escapeAttr(props.anchorId)}"` : '';
const headerBg = props.headerBg || '#f8fafc'; // Sanitized -- raw string-interpolation sinks in the <details>/<summary>
const headerColor = props.headerColor || '#18181b'; // style attributes below.
const contentBg = props.contentBg || '#ffffff'; const headerBg = cssValue(props.headerBg) || '#f8fafc';
const borderColor = props.borderColor || '#e2e8f0'; const headerColor = cssValue(props.headerColor) || '#18181b';
const contentBg = cssValue(props.contentBg) || '#ffffff';
const borderColor = cssValue(props.borderColor) || '#e2e8f0';
const items = props.items || defaultItems; const items = props.items || defaultItems;
const panels = items.map((item, i) => { const panels = items.map((item, i) => {
@@ -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 { CtaButton, normalizeCtas, ctaInlineStyle, ctasToHtml } from './_cta-helpers'; import { CtaButton, normalizeCtas, ctaInlineStyle, ctasToHtml } from './_cta-helpers';
import { escapeHtml, escapeAttr } from '../../utils/escape'; import { escapeHtml, escapeAttr, cssValue } from '../../utils/escape';
interface CallToActionProps { interface CallToActionProps {
heading?: string; heading?: string;
@@ -144,7 +144,9 @@ CallToAction.craft = {
(CallToAction as any).toHtml = (props: CallToActionProps, _childrenHtml: string) => { (CallToAction as any).toHtml = (props: CallToActionProps, _childrenHtml: string) => {
const bgType = props.bgType || 'gradient'; const bgType = props.bgType || 'gradient';
const bgValue = props.bgValue || defaultGradient; const bgValue = props.bgValue || defaultGradient;
const textColor = props.textColor || '#ffffff'; // Sanitized -- raw string-interpolation sink in the heading/description
// style attributes below.
const textColor = cssValue(props.textColor) || '#ffffff';
const buttonColor = props.buttonColor || '#ffffff'; const buttonColor = props.buttonColor || '#ffffff';
const isButtonDark = buttonColor === '#ffffff' || buttonColor === '#f8fafc'; const isButtonDark = buttonColor === '#ffffff' || buttonColor === '#f8fafc';
const buttonTextColor = isButtonDark ? '#18181b' : '#ffffff'; const buttonTextColor = isButtonDark ? '#18181b' : '#ffffff';
@@ -91,3 +91,51 @@ describe('ContentSlider.toHtml autoplay silences aria-live and is pausable (F-ex
expect(html).not.toMatch(/visibilitychange/); expect(html).not.toMatch(/visibilitychange/);
}); });
}); });
describe('ContentSlider.toHtml renders slide.imageSrc as a background-image (INT)', () => {
test('a slide with imageSrc set exports a background-image referencing it', () => {
const slidesWithImage = [
{ type: 'image' as const, imageSrc: 'https://example.com/photo.jpg', heading: 'One' },
];
const { html } = toHtml({ slides: slidesWithImage }, '');
expect(html).toContain("background-image:url('https://example.com/photo.jpg')");
});
test('a slide with no imageSrc falls back to bgColor (no broken/empty background-image url)', () => {
const slidesNoImage = [
{ type: 'image' as const, imageSrc: '', heading: 'One', bgColor: '#123456' },
];
const { html } = toHtml({ slides: slidesNoImage }, '');
expect(html).not.toContain('background-image:url(');
expect(html).toContain('background-color:#123456');
});
test('a slide with a data:image/svg+xml imageSrc exports a non-empty background-image url (safeImageUrl, not safeUrl)', () => {
const svgDataUri = 'data:image/svg+xml,%3Csvg%2F%3E';
const slidesWithSvg = [
{ type: 'image' as const, imageSrc: svgDataUri, heading: 'One' },
];
const { html } = toHtml({ slides: slidesWithSvg }, '');
expect(html).toContain(`background-image:url('${svgDataUri}')`);
});
});
describe('ContentSlider.toHtml interval is NOT runtime-type-checked -- must be coerced before it reaches the inline <script> numeric context', () => {
test('a malicious interval string cannot inject arbitrary JS into the autoplay setInterval call', () => {
const malicious = '5000);alert(document.domain);//';
const { html } = toHtml({ slides, autoplay: true, interval: malicious }, '');
expect(html).not.toContain('alert(document.domain)');
// the setInterval call must still be well-formed with a plain numeral delay
expect(html).toMatch(/setInterval\(function\(\)\{show\(current\+1\);\},\d+\);/);
});
test('a non-numeric interval falls back to a safe default delay', () => {
const { html } = toHtml({ slides, autoplay: true, interval: 'not-a-number' }, '');
expect(html).toMatch(/setInterval\(function\(\)\{show\(current\+1\);\},5000\);/);
});
test('a normal numeric interval still renders as the exact configured delay', () => {
const { html } = toHtml({ slides, autoplay: true, interval: 3000 }, '');
expect(html).toMatch(/setInterval\(function\(\)\{show\(current\+1\);\},3000\);/);
});
});
@@ -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 } from '../../utils/escape'; import { escapeHtml, escapeAttr, safeUrl, safeImageUrl, scopeId, cssValue } from '../../utils/escape';
interface Slide { interface Slide {
type: 'image' | 'content'; type: 'image' | 'content';
@@ -250,11 +250,24 @@ ContentSlider.craft = {
} = props; } = props;
const items = slides.length > 0 ? slides : defaultSlides; const items = slides.length > 0 ? slides : defaultSlides;
// Number() coercion: `interval` is declared `number` in TS but is NOT
// type-checked at runtime -- it arrives raw via the AI `update_props`
// path or a deserialized saved-state blob and is interpolated directly
// into the inline <script>'s `setInterval(fn, ${interval})` call below as
// a bare JS numeral (no quotes around it). A string like
// `5000);alert(1);//` would previously close the setInterval() call and
// splice arbitrary JS into the page's own <script> tag -- worse than an
// HTML attribute breakout, since it runs unconditionally on page load.
// Number() of anything non-numeric collapses safely to NaN, so we fall
// back to the 5000ms default rather than ever interpolating a
// non-numeral.
const intervalNum = Number(interval);
const safeInterval = Number.isFinite(intervalNum) && intervalNum > 0 ? intervalNum : 5000;
// Deterministic AND unique id, scoped on the Craft node id, for this // Deterministic AND unique id, scoped on the Craft node id, for this
// slider's slide/dot element ids and inline-script globals -- so two // slider's slide/dot element ids and inline-script globals -- so two
// ContentSlider instances (e.g. both left at default slides) don't // ContentSlider instances (e.g. both left at default slides) don't
// collide and end up driving each other's rotation. // collide and end up driving each other's rotation.
const uid = scopeId(nodeId, JSON.stringify(items) + interval, 'cs'); const uid = scopeId(nodeId, JSON.stringify(items) + safeInterval, 'cs');
const sectionStyle = cssPropsToString({ const sectionStyle = cssPropsToString({
position: 'relative', position: 'relative',
@@ -266,11 +279,14 @@ ContentSlider.craft = {
const slidesHtml = items.map((slide, i) => { const slidesHtml = items.map((slide, i) => {
const hasBgImage = slide.imageSrc; const hasBgImage = slide.imageSrc;
// Sanitized -- slide.bgColor is a per-slide raw string-interpolation
// sink (a malicious value could break out of the style="..." attribute).
const safeBgColor = cssValue(slide.bgColor) || '#3b82f6';
const bgStyle = hasBgImage 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:${slide.bgColor}` ? `background-image:${safeBgColor}`
: `background-color:${slide.bgColor || '#3b82f6'}`; : `background-color:${safeBgColor}`;
const contentParts: string[] = []; const contentParts: string[] = [];
if (slide.heading) { if (slide.heading) {
@@ -336,7 +352,7 @@ ContentSlider.craft = {
window["${uid}_prev"]=function(){ markManualNav(); show(current-1); }; window["${uid}_prev"]=function(){ markManualNav(); show(current-1); };
${autoplayActive ? ` ${autoplayActive ? `
var timer=null; var timer=null;
function start(){ if(!timer && document.visibilityState!=="hidden"){ timer=setInterval(function(){show(current+1);},${interval}); } } function start(){ if(!timer && document.visibilityState!=="hidden"){ timer=setInterval(function(){show(current+1);},${safeInterval}); } }
function stop(){ if(timer){ clearInterval(timer); timer=null; } } function stop(){ if(timer){ clearInterval(timer); timer=null; } }
var root=document.getElementById(uid); var root=document.getElementById(uid);
if(root){ if(root){
+5 -3
View File
@@ -1,7 +1,7 @@
import React, { CSSProperties, useEffect, useState } from 'react'; import React, { CSSProperties, useEffect, 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 { escapeHtml, escapeAttr, scopeId } from '../../utils/escape'; import { escapeHtml, escapeAttr, scopeId, cssValue } from '../../utils/escape';
interface CountdownProps { interface CountdownProps {
targetDate?: string; targetDate?: string;
@@ -154,10 +154,12 @@ Countdown.craft = {
targetDate = DEFAULT_TARGET, targetDate = DEFAULT_TARGET,
heading = 'Coming Soon', heading = 'Coming Soon',
style = {}, style = {},
digitColor = '#ffffff',
labelColor = 'rgba(255,255,255,0.7)',
bgColor = '#18181b', bgColor = '#18181b',
} = props; } = props;
// Sanitized -- raw string-interpolation sinks in the heading/digit/label
// style attributes below.
const digitColor = cssValue(props.digitColor) || '#ffffff';
const labelColor = cssValue(props.labelColor) || 'rgba(255,255,255,0.7)';
const sectionStyle = cssPropsToString({ const sectionStyle = cssPropsToString({
padding: '60px 20px', padding: '60px 20px',
@@ -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\(\)/);
});
});
+41 -7
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 } from '../../utils/escape'; import { escapeHtml, escapeAttr, safeImageUrl, scopeId, cssValue } from '../../utils/escape';
interface GalleryImage { interface GalleryImage {
src: string; src: string;
@@ -130,8 +130,14 @@ Gallery.craft = {
...props.style, ...props.style,
}); });
const images = props.images || defaultImages; const images = props.images || defaultImages;
const columns = props.columns || 3; // Number() coercion: `columns` is a raw string-interpolation sink into the
const gap = props.gap || '16px'; // grid style attribute below (repeat(${columns},1fr)) -- a non-numeric
// (e.g. hand-crafted/AI-generated tree) value would otherwise be able to
// break out; Number() of anything non-numeric collapses safely to NaN.
const columns = Number(props.columns) || 3;
// Sanitized -- gap is a raw string-interpolation sink into the grid style
// attribute below.
const gap = cssValue(props.gap) || '16px';
const lightbox = props.lightbox || false; const lightbox = props.lightbox || false;
// Deterministic AND unique id, scoped on the Craft node id, for this // Deterministic AND unique id, scoped on the Craft node id, for this
@@ -148,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 ');
@@ -160,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]');
@@ -184,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>`;
} }
@@ -0,0 +1,35 @@
import { describe, test, expect } from 'vitest';
import { HeroSimple } from './HeroSimple';
const toHtml = (HeroSimple as any).toHtml;
describe('HeroSimple.toHtml textAlign enum sink (attacker-controlled prop, not enforced at runtime)', () => {
test('malicious textAlign value cannot break out of the content div style attribute', () => {
const { html } = toHtml({
heading: 'Hi',
subtitle: 'There',
textAlign: 'center;"><script>alert(1)</script>',
}, '');
expect(html).not.toContain('<script>alert(1)</script>');
expect(html).not.toContain('center;">');
});
test('unrecognized textAlign value falls back to a safe default rather than being echoed raw', () => {
const { html } = toHtml({ heading: 'Hi', subtitle: 'There', textAlign: 'not-a-real-value' as any }, '');
expect(html).not.toContain('text-align:not-a-real-value');
});
test('valid textAlign values are preserved', () => {
const { html: left } = toHtml({ heading: 'Hi', subtitle: 'There', textAlign: 'left' }, '');
expect(left).toContain('text-align:left');
const { html: right } = toHtml({ heading: 'Hi', subtitle: 'There', textAlign: 'right' }, '');
expect(right).toContain('text-align:right');
});
test('normal default render is sane', () => {
const { html } = toHtml({ heading: 'Welcome', subtitle: 'Sub text' }, '');
expect(html).toContain('Welcome');
expect(html).toContain('Sub text');
expect(html).toContain('text-align:center');
});
});
+13 -5
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 { CtaButton, normalizeCtas, ctaInlineStyle, ctasToHtml } from './_cta-helpers'; import { CtaButton, normalizeCtas, ctaInlineStyle, ctasToHtml } from './_cta-helpers';
import { escapeHtml, escapeAttr, safeUrl } from '../../utils/escape'; import { escapeHtml, escapeAttr, safeUrl, cssValue } from '../../utils/escape';
interface HeroProps { interface HeroProps {
heading?: string; heading?: string;
@@ -220,7 +220,8 @@ HeroSimple.craft = {
let overlayHtml = ''; let overlayHtml = '';
if ((props.overlayOpacity || 0) > 0) { if ((props.overlayOpacity || 0) > 0) {
overlayHtml = `<div style="position:absolute;top:0;left:0;right:0;bottom:0;background-color:${props.overlayColor || '#000'};opacity:${(props.overlayOpacity || 0) / 100};z-index:1"></div>`; const overlayColor = cssValue(props.overlayColor) || '#000';
overlayHtml = `<div style="position:absolute;top:0;left:0;right:0;bottom:0;background-color:${overlayColor};opacity:${(props.overlayOpacity || 0) / 100};z-index:1"></div>`;
} }
let videoHtml = ''; let videoHtml = '';
@@ -228,7 +229,13 @@ HeroSimple.craft = {
videoHtml = `<video src="${escapeAttr(safeUrl(props.bgVideo))}" autoplay muted loop playsinline style="position:absolute;top:0;left:0;width:100%;height:100%;object-fit:cover;z-index:0"></video>`; videoHtml = `<video src="${escapeAttr(safeUrl(props.bgVideo))}" autoplay muted loop playsinline style="position:absolute;top:0;left:0;width:100%;height:100%;object-fit:cover;z-index:0"></video>`;
} }
const textAlign = props.textAlign || 'center'; // Allowlisted -- `textAlign` is declared as a 'left'|'center'|'right' union
// but arrives unchecked via AI update_props / deserialized state; it is
// interpolated raw into the content div's style attribute below, so any
// other value must collapse to a known-safe default rather than being
// echoed into the markup.
const ALLOWED_TEXT_ALIGN = ['left', 'center', 'right'];
const textAlign = ALLOWED_TEXT_ALIGN.includes(props.textAlign as string) ? (props.textAlign as string) : 'center';
const justifyBtn = textAlign === 'center' ? 'center' : textAlign === 'right' ? 'flex-end' : 'flex-start'; const justifyBtn = textAlign === 'center' ? 'center' : textAlign === 'right' ? 'flex-end' : 'flex-start';
const ctas = normalizeCtas(props); const ctas = normalizeCtas(props);
@@ -239,12 +246,13 @@ HeroSimple.craft = {
}); });
const idAttr = props.anchorId ? ` id="${escapeAttr(props.anchorId)}"` : ''; const idAttr = props.anchorId ? ` id="${escapeAttr(props.anchorId)}"` : '';
const heroTextColor = cssValue(props.textColor) || '#fff';
return { return {
html: `<section${idAttr} style="${sectionStyle}"> html: `<section${idAttr} style="${sectionStyle}">
${videoHtml}${overlayHtml} ${videoHtml}${overlayHtml}
<div style="max-width:800px;width:100%;position:relative;z-index:2;text-align:${textAlign}"> <div style="max-width:800px;width:100%;position:relative;z-index:2;text-align:${textAlign}">
<h1 style="font-size:48px;font-weight:700;color:${props.textColor || '#fff'};margin-bottom:16px;line-height:1.2">${escapeHtml(props.heading || '')}</h1> <h1 style="font-size:48px;font-weight:700;color:${heroTextColor};margin-bottom:16px;line-height:1.2">${escapeHtml(props.heading || '')}</h1>
<p style="font-size:20px;color:${props.textColor || '#fff'};opacity:0.85;margin-bottom:32px;line-height:1.6;white-space:pre-line">${escapeHtml(props.subtitle || '')}</p> <p style="font-size:20px;color:${heroTextColor};opacity:0.85;margin-bottom:32px;line-height:1.6;white-space:pre-line">${escapeHtml(props.subtitle || '')}</p>
<div style="display:flex;gap:12px;justify-content:${justifyBtn};flex-wrap:wrap">${buttonsHtml}</div> <div style="display:flex;gap:12px;justify-content:${justifyBtn};flex-wrap:wrap">${buttonsHtml}</div>
</div> </div>
</section>`, </section>`,
@@ -47,3 +47,29 @@ describe('NumberCounter.toHtml deterministic + unique scope ids (thread node id,
expect(wrapId1).not.toBe(wrapId2); expect(wrapId1).not.toBe(wrapId2);
}); });
}); });
describe('NumberCounter.toHtml counter.number is NOT runtime-type-checked -- must be sanitized before it reaches data-target', () => {
test('a malicious counter.number cannot break out of the data-target attribute to inject a <script> tag', () => {
const malicious = [
{ number: '150"><script>alert(1)</script>', suffix: '+', label: 'Evil' },
];
const { html } = toHtml({ counters: malicious }, '', 'node-nc-evil1');
expect(html).not.toContain('<script>alert(1)</script>');
expect(html).not.toContain('"><script>');
});
test('a malicious counter.number cannot break out of the data-target attribute to inject an onmouseover handler', () => {
const malicious = [
{ number: '150" onmouseover="alert(1)', suffix: '+', label: 'Evil' },
];
const { html } = toHtml({ counters: malicious }, '', 'node-nc-evil2');
expect(html).not.toContain('onmouseover=');
expect(html).not.toMatch(/data-target="150" onmouseover/);
});
test('normal numeric counter.number values still render as data-target="150"', () => {
const normal = [{ number: 150, suffix: '+', label: 'Projects' }];
const { html } = toHtml({ counters: normal }, '', 'node-nc-normal');
expect(html).toContain('data-target="150"');
});
});
@@ -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, scopeId } from '../../utils/escape'; import { escapeHtml, escapeAttr, scopeId, cssValue } from '../../utils/escape';
interface Counter { interface Counter {
number: number; number: number;
@@ -112,12 +112,17 @@ NumberCounter.craft = {
(NumberCounter as any).toHtml = (props: NumberCounterProps, _childrenHtml: string, nodeId?: string) => { (NumberCounter as any).toHtml = (props: NumberCounterProps, _childrenHtml: string, nodeId?: string) => {
const { const {
counters = defaultCounters, counters = defaultCounters,
columns = 4,
numberColor = '#3b82f6',
labelColor = '#6b7280',
numberSize = '48px',
style = {}, style = {},
} = props; } = props;
// Number() coercion: `columns` is a raw string-interpolation sink into the
// grid style attribute below (repeat(${columns},1fr)); Number() of
// anything non-numeric collapses safely to NaN instead of breaking out.
const columns = Number(props.columns) || 4;
// Sanitized -- raw string-interpolation sinks in the counter/label spans
// below.
const numberColor = cssValue(props.numberColor) || '#3b82f6';
const labelColor = cssValue(props.labelColor) || '#6b7280';
const numberSize = cssValue(props.numberSize) || '48px';
const items = counters.length > 0 ? counters : defaultCounters; const items = counters.length > 0 ? counters : defaultCounters;
@@ -135,8 +140,20 @@ NumberCounter.craft = {
}); });
const countersHtml = items.map((counter, i) => { const countersHtml = items.map((counter, i) => {
// Number() coercion + escapeAttr: `counter.number` is declared `number`
// per-item inside an array prop, but is NOT type-checked at runtime --
// it arrives raw via the AI `update_props` path or a deserialized
// saved-state blob and was previously interpolated straight into this
// data-target attribute, letting a string like `150"><script>...`
// break out of the attribute and inject markup. Number() collapses any
// non-numeric value safely to NaN (then 0), and escapeAttr is kept as
// defense-in-depth in case Number()'s string coercion output ever
// contains a stray character (it can't today, but the sink should never
// rely solely on the coercion).
const numberVal = Number(counter.number);
const safeNumber = Number.isFinite(numberVal) ? numberVal : 0;
return `<div style="display:flex;flex-direction:column;align-items:center;gap:8px"> return `<div style="display:flex;flex-direction:column;align-items:center;gap:8px">
<span id="${uid}_n${i}" data-target="${counter.number}" data-suffix="${escapeAttr(counter.suffix)}" style="font-size:${numberSize};font-weight:700;color:${numberColor};line-height:1.1;font-family:Inter,sans-serif">0${escapeHtml(counter.suffix)}</span> <span id="${uid}_n${i}" data-target="${escapeAttr(String(safeNumber))}" data-suffix="${escapeAttr(counter.suffix)}" style="font-size:${numberSize};font-weight:700;color:${numberColor};line-height:1.1;font-family:Inter,sans-serif">0${escapeHtml(counter.suffix)}</span>
<span style="font-size:15px;color:${labelColor};font-family:Inter,sans-serif;font-weight:500">${escapeHtml(counter.label)}</span> <span style="font-size:15px;color:${labelColor};font-family:Inter,sans-serif;font-weight:500">${escapeHtml(counter.label)}</span>
</div>`; </div>`;
}).join('\n '); }).join('\n ');
@@ -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, cssValue } from '../../utils/escape';
interface PricingPlan { interface PricingPlan {
name: string; name: string;
@@ -225,7 +225,9 @@ PricingTable.craft = {
}); });
const idAttr = props.anchorId ? ` id="${escapeAttr(props.anchorId)}"` : ''; const idAttr = props.anchorId ? ` id="${escapeAttr(props.anchorId)}"` : '';
const plans = props.plans || defaultPlans; const plans = props.plans || defaultPlans;
const featuredBg = props.featuredBg || '#3b82f6'; // Sanitized -- featuredBg is a raw string-interpolation sink below (drives
// cardBg/btnBg/btnColor, all raw-interpolated into style="...").
const featuredBg = cssValue(props.featuredBg) || '#3b82f6';
const cards = plans.map((plan) => { const cards = plans.map((plan) => {
const cardBg = plan.isFeatured ? featuredBg : '#ffffff'; const cardBg = plan.isFeatured ? featuredBg : '#ffffff';
+10 -6
View File
@@ -1,7 +1,7 @@
import React, { CSSProperties, useState } from 'react'; 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 { escapeHtml, escapeAttr, scopeId } from '../../utils/escape'; import { escapeHtml, escapeAttr, scopeId, cssValue } from '../../utils/escape';
interface TabItem { interface TabItem {
label: string; label: string;
@@ -131,11 +131,15 @@ Tabs.craft = {
}); });
const idAttr = props.anchorId ? ` id="${escapeAttr(props.anchorId)}"` : ''; const idAttr = props.anchorId ? ` id="${escapeAttr(props.anchorId)}"` : '';
const tabs = props.tabs || defaultTabs; const tabs = props.tabs || defaultTabs;
const activeTabBg = props.activeTabBg || '#3b82f6'; // Sanitized -- raw string-interpolation sinks below, both into style="..."
const activeTabColor = props.activeTabColor || '#ffffff'; // attributes AND into an inline <script> as single-quoted JS string
const inactiveTabBg = props.inactiveTabBg || '#f1f5f9'; // literals (a stray `'` there breaks out of the JS string, not just CSS);
const inactiveTabColor = props.inactiveTabColor || '#64748b'; // cssValue strips quotes too so it neutralizes both contexts at once.
const contentBg = props.contentBg || '#ffffff'; const activeTabBg = cssValue(props.activeTabBg) || '#3b82f6';
const activeTabColor = cssValue(props.activeTabColor) || '#ffffff';
const inactiveTabBg = cssValue(props.inactiveTabBg) || '#f1f5f9';
const inactiveTabColor = cssValue(props.inactiveTabColor) || '#64748b';
const contentBg = cssValue(props.contentBg) || '#ffffff';
// tabId scopes the functional wiring (onclick/getElementById) as well as // tabId scopes the functional wiring (onclick/getElementById) as well as
// the ARIA tab<->panel linking ids. It must be BOTH deterministic (so // the ARIA tab<->panel linking ids. It must be BOTH deterministic (so
@@ -46,3 +46,28 @@ describe('Testimonials.toHtml decorative star icons (F2.5)', () => {
stars.forEach((tag: string) => expect(tag).toContain('aria-hidden="true"')); stars.forEach((tag: string) => expect(tag).toContain('aria-hidden="true"'));
}); });
}); });
describe('Testimonials.toHtml rating aria-label sink (attacker-controlled `rating`, typed number but unchecked)', () => {
test('malicious rating value cannot break out of the star row aria-label attribute', () => {
const malicious = [
{ quote: 'Q', name: 'N', title: 'T', rating: '5"><script>alert(1)</script>' as any },
];
const { html } = toHtml({ testimonials: malicious, layout: 'grid' }, '');
expect(html).not.toContain('<script>alert(1)</script>');
expect(html).not.toContain('5"><script>');
});
test('non-numeric rating falls back to a safe numeric value', () => {
const malicious = [
{ quote: 'Q', name: 'N', title: 'T', rating: 'not-a-number' as any },
];
const { html } = toHtml({ testimonials: malicious, layout: 'grid' }, '');
expect(html).toMatch(/aria-label="Rating: 0 out of 5"/);
});
test('normal numeric rating still renders correctly', () => {
const { html } = toHtml({ testimonials, layout: 'grid' }, '');
expect(html).toContain('aria-label="Rating: 5 out of 5"');
expect(html).toContain('aria-label="Rating: 4 out of 5"');
});
});
+17 -6
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 } from '../../utils/escape'; import { escapeHtml, escapeAttr, cssValue } from '../../utils/escape';
interface Testimonial { interface Testimonial {
quote: string; quote: string;
@@ -41,10 +41,16 @@ function renderStars(count: number, color: string): React.ReactNode {
} }
function starsHtml(count: number, color: string): string { function starsHtml(count: number, color: string): string {
// `count` is `Testimonial.rating`, typed `number` but arrives unchecked via
// AI update_props / deserialized state -- coerce to a real number before
// it's interpolated into the aria-label attribute below (both the loop
// comparison and the escapeAttr(String(...)) call are safe against any
// non-numeric/garbage value).
const safeCount = Number(count) || 0;
const stars = [1, 2, 3, 4, 5].map((i) => const stars = [1, 2, 3, 4, 5].map((i) =>
`<i class="fa ${i <= count ? 'fa-star' : 'fa-star-o'}" style="color:${color};font-size:14px" aria-hidden="true"></i>` `<i class="fa ${i <= safeCount ? 'fa-star' : 'fa-star-o'}" style="color:${color};font-size:14px" aria-hidden="true"></i>`
).join(''); ).join('');
return `<div style="display:flex;gap:2px;justify-content:center;margin-bottom:12px" role="img" aria-label="Rating: ${count} out of 5">${stars}</div>`; return `<div style="display:flex;gap:2px;justify-content:center;margin-bottom:12px" role="img" aria-label="${escapeAttr(`Rating: ${safeCount} out of 5`)}">${stars}</div>`;
} }
export const Testimonials: UserComponent<TestimonialsProps> = ({ export const Testimonials: UserComponent<TestimonialsProps> = ({
@@ -139,11 +145,16 @@ Testimonials.craft = {
const { const {
testimonials = defaultTestimonials, testimonials = defaultTestimonials,
layout = 'grid', layout = 'grid',
columns = 3,
style = {}, style = {},
cardBg = '#f8fafc',
starColor = '#f59e0b',
} = props; } = props;
// Number() coercion: `columns` is a raw string-interpolation sink into the
// grid style attribute below (repeat(${columns},1fr)); Number() of
// anything non-numeric collapses safely to NaN instead of breaking out.
const columns = Number(props.columns) || 3;
// Sanitized -- raw string-interpolation sinks below (cardCss / starsHtml
// style attributes).
const cardBg = cssValue(props.cardBg) || '#f8fafc';
const starColor = cssValue(props.starColor) || '#f59e0b';
const items = testimonials.length > 0 ? testimonials : defaultTestimonials; const items = testimonials.length > 0 ? testimonials : defaultTestimonials;
+11 -4
View File
@@ -1,5 +1,5 @@
import { CSSProperties } from 'react'; import { CSSProperties } from 'react';
import { escapeHtml, escapeAttr, safeUrl } from '../../utils/escape'; import { escapeHtml, escapeAttr, safeUrl, cssValue } from '../../utils/escape';
export type CtaVariant = 'primary' | 'outline' | 'ghost'; export type CtaVariant = 'primary' | 'outline' | 'ghost';
@@ -68,14 +68,21 @@ export function ctaInlineStyle(cta: CtaButton, defaults: CtaStyleDefaults): CSSP
export function ctaCssString(cta: CtaButton, defaults: CtaStyleDefaults): string { export function ctaCssString(cta: CtaButton, defaults: CtaStyleDefaults): string {
const variant = cta.variant || 'primary'; const variant = cta.variant || 'primary';
// Sanitized -- these are raw string-interpolation sinks into style="...".
// Callers pass user-controlled design-token colors (e.g. HeroSimple's
// buttonBgColor/buttonTextColor/textColor) through CtaStyleDefaults, so
// sanitize once here rather than at every call site.
const outlineText = cssValue(defaults.outlineText) || '#000000';
const primaryBg = cssValue(defaults.primaryBg) || '#000000';
const primaryText = cssValue(defaults.primaryText) || '#ffffff';
switch (variant) { switch (variant) {
case 'outline': case 'outline':
return `display:inline-block;padding:14px 36px;background-color:transparent;color:${defaults.outlineText};text-decoration:none;border-radius:8px;font-weight:600;font-size:16px;border:2px solid ${defaults.outlineText}`; return `display:inline-block;padding:14px 36px;background-color:transparent;color:${outlineText};text-decoration:none;border-radius:8px;font-weight:600;font-size:16px;border:2px solid ${outlineText}`;
case 'ghost': case 'ghost':
return `display:inline-block;padding:14px 24px;background-color:transparent;color:${defaults.outlineText};text-decoration:underline;border-radius:8px;font-weight:600;font-size:16px`; return `display:inline-block;padding:14px 24px;background-color:transparent;color:${outlineText};text-decoration:underline;border-radius:8px;font-weight:600;font-size:16px`;
case 'primary': case 'primary':
default: default:
return `display:inline-block;padding:14px 36px;background-color:${defaults.primaryBg};color:${defaults.primaryText};text-decoration:none;border-radius:8px;font-weight:600;font-size:16px`; return `display:inline-block;padding:14px 36px;background-color:${primaryBg};color:${primaryText};text-decoration:none;border-radius:8px;font-weight:600;font-size:16px`;
} }
} }
+38 -5
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';
@@ -8,6 +8,8 @@ import { exportBodyHtml } from '../utils/html-export';
interface CanvasProps { interface CanvasProps {
device: DeviceMode; device: DeviceMode;
/** Item 10: when false, applies `.guides-off` to hide the dashed drop-target guides. */
showGuides: boolean;
} }
/** /**
@@ -81,19 +83,47 @@ const ZonePreview: React.FC<{ craftState: string | null; zone: 'header' | 'foote
<div <div
ref={containerRef} ref={containerRef}
data-zone-preview={zone} data-zone-preview={zone}
className="zone-preview-sep"
style={{ style={{
width: '100%', width: '100%',
position: 'relative', position: 'relative',
pointerEvents: 'none', pointerEvents: 'none',
userSelect: 'none', userSelect: 'none',
borderBottom: zone === 'header' ? '1px dashed rgba(245,158,11,0.3)' : 'none', borderBottom: zone === 'header' ? '1px dashed rgba(148,163,184,0.25)' : 'none',
borderTop: zone === 'footer' ? '1px dashed rgba(245,158,11,0.3)' : 'none', borderTop: zone === 'footer' ? '1px dashed rgba(148,163,184,0.25)' : 'none',
}} }}
/> />
); );
}; };
export const Canvas: React.FC<CanvasProps> = ({ device }) => { /**
* 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, showGuides }) => {
const width = DEVICE_WIDTHS[device]; const width = DEVICE_WIDTHS[device];
const { isEditingHeader, isEditingFooter, headerPage, footerPage } = usePages(); const { isEditingHeader, isEditingFooter, headerPage, footerPage } = usePages();
@@ -110,7 +140,7 @@ export const Canvas: React.FC<CanvasProps> = ({ device }) => {
return ( return (
<div className="editor-canvas"> <div className="editor-canvas">
<div <div
className="canvas-device-frame" className={`canvas-device-frame${showGuides ? '' : ' guides-off'}`}
style={{ style={{
width, width,
maxWidth: '100%', maxWidth: '100%',
@@ -140,6 +170,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 +179,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" />
+33 -2
View File
@@ -9,8 +9,34 @@ import { useContextMenu } from '../hooks/useContextMenu';
import { useKeyboardShortcuts } from '../hooks/useKeyboardShortcuts'; import { useKeyboardShortcuts } from '../hooks/useKeyboardShortcuts';
import { DeviceMode } from '../types'; import { DeviceMode } from '../types';
const SHOW_GUIDES_STORAGE_KEY = 'craft-show-guides';
function loadShowGuides(): boolean {
try {
const stored = window.localStorage.getItem(SHOW_GUIDES_STORAGE_KEY);
return stored === null ? true : stored === '1';
} catch {
return true;
}
}
export const EditorShell: React.FC = () => { export const EditorShell: React.FC = () => {
const [device, setDevice] = useState<DeviceMode>('desktop'); const [device, setDevice] = useState<DeviceMode>('desktop');
// Item 10: canvas dashed "guide" outlines toggle -- default ON, persisted
// so the choice survives a reload. Lifted here (rather than owned by
// TopBar or Canvas alone) because the toggle button lives in TopBar but
// the `.guides-off` class it drives is applied to Canvas's
// `.canvas-device-frame`, mirroring how `device` is already lifted for
// the same reason.
const [showGuides, setShowGuidesState] = useState<boolean>(loadShowGuides);
const setShowGuides = useCallback((next: boolean) => {
setShowGuidesState(next);
try {
window.localStorage.setItem(SHOW_GUIDES_STORAGE_KEY, next ? '1' : '0');
} catch {
// Storage unavailable (private browsing, etc.) -- in-memory state still works.
}
}, []);
const { menuState, show: showMenu, hide: hideMenu } = useContextMenu(); const { menuState, show: showMenu, hide: hideMenu } = useContextMenu();
const { query } = useEditor(); const { query } = useEditor();
@@ -34,11 +60,16 @@ export const EditorShell: React.FC = () => {
return ( return (
<div className="editor-app"> <div className="editor-app">
<TopBar device={device} onDeviceChange={setDevice} /> <TopBar
device={device}
onDeviceChange={setDevice}
showGuides={showGuides}
onToggleGuides={() => setShowGuides(!showGuides)}
/>
<div className="editor-container"> <div className="editor-container">
<LeftPanel /> <LeftPanel />
<div onContextMenu={handleContextMenu} style={{ flex: 1, display: 'flex', minWidth: 0 }}> <div onContextMenu={handleContextMenu} style={{ flex: 1, display: 'flex', minWidth: 0 }}>
<Canvas device={device} /> <Canvas device={device} showGuides={showGuides} />
</div> </div>
<RightPanel /> <RightPanel />
</div> </div>
+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();
});
});
+169
View File
@@ -0,0 +1,169 @@
import { describe, test, expect, vi, beforeEach } from 'vitest';
import React from 'react';
import { createRoot, Root } from 'react-dom/client';
import { act } from 'react-dom/test-utils';
/* Same DOM-harness pattern as Footer.editguard.test.tsx: mock @craftjs/core
so RenderNode (the <Editor onRender> override) can be driven without a
real Editor tree. document.body doubles as the portal target, same as
the component itself uses. */
let mockNode: {
id: string;
selected: boolean;
hovered: boolean;
dom: HTMLElement | null;
displayName: string;
parent: string | null;
isCanvas: boolean;
};
const selectNodeSpy = vi.fn();
vi.mock('@craftjs/core', () => ({
useEditor: () => ({ actions: { selectNode: selectNodeSpy } }),
useNode: (collect?: (node: any) => any) => {
const node = {
events: { selected: mockNode.selected, hovered: mockNode.hovered },
dom: mockNode.dom,
data: {
custom: {},
displayName: mockNode.displayName,
parent: mockNode.parent,
isCanvas: mockNode.isCanvas,
},
};
return { id: mockNode.id, ...(collect ? collect(node) : {}) };
},
}));
import { RenderNode } from './RenderNode';
let container: HTMLDivElement;
let root: Root;
let nodeDom: HTMLElement;
function render(ui: React.ReactElement) {
container = document.createElement('div');
document.body.appendChild(container);
act(() => {
root = createRoot(container);
root.render(ui);
});
}
beforeEach(() => {
nodeDom = document.createElement('div');
document.body.appendChild(nodeDom);
mockNode = {
id: 'node-1',
selected: false,
hovered: false,
dom: nodeDom,
displayName: 'Heading',
parent: 'ROOT',
isCanvas: false,
};
selectNodeSpy.mockClear();
});
const rendered = <span data-testid="inner">hello</span>;
describe('RenderNode (Editor onRender override)', () => {
test('passes render through untouched when not selected', () => {
render(<RenderNode render={rendered} />);
expect(container.querySelector('[data-testid="inner"]')).not.toBeNull();
expect(document.querySelector('.component-indicator')).toBeNull();
container.remove();
nodeDom.remove();
});
test('shows the badge with the displayName when selected', () => {
mockNode.selected = true;
render(<RenderNode render={rendered} />);
const badge = document.querySelector('.component-indicator');
expect(badge).not.toBeNull();
expect(badge?.textContent).toContain('Heading');
container.remove();
nodeDom.remove();
document.querySelector('.component-indicator')?.remove();
});
test('never shows a badge for ROOT even if "selected"', () => {
mockNode.selected = true;
mockNode.id = 'ROOT';
render(<RenderNode render={rendered} />);
expect(document.querySelector('.component-indicator')).toBeNull();
container.remove();
nodeDom.remove();
});
test('chevron click selects the parent node', () => {
mockNode.selected = true;
mockNode.parent = 'parent-42';
render(<RenderNode render={rendered} />);
const chevron = document.querySelector('.component-indicator-parent-btn') as HTMLElement;
expect(chevron).not.toBeNull();
act(() => {
chevron.dispatchEvent(new MouseEvent('mousedown', { bubbles: true }));
});
expect(selectNodeSpy).toHaveBeenCalledWith('parent-42');
container.remove();
nodeDom.remove();
document.querySelector('.component-indicator')?.remove();
});
test('no chevron when there is no parent', () => {
mockNode.selected = true;
mockNode.parent = null;
render(<RenderNode render={rendered} />);
expect(document.querySelector('.component-indicator-parent-btn')).toBeNull();
container.remove();
nodeDom.remove();
document.querySelector('.component-indicator')?.remove();
});
test('tags a droppable (isCanvas) node dom with data-craft-node', () => {
mockNode.isCanvas = true;
render(<RenderNode render={rendered} />);
expect(nodeDom.hasAttribute('data-craft-node')).toBe(true);
container.remove();
nodeDom.remove();
});
test('does not tag a non-canvas (leaf) node dom with data-craft-node', () => {
mockNode.isCanvas = false;
render(<RenderNode render={rendered} />);
expect(nodeDom.hasAttribute('data-craft-node')).toBe(false);
container.remove();
nodeDom.remove();
});
test('never tags ROOT with data-craft-node even though ROOT is a canvas', () => {
mockNode.id = 'ROOT';
mockNode.isCanvas = true;
render(<RenderNode render={rendered} />);
expect(nodeDom.hasAttribute('data-craft-node')).toBe(false);
container.remove();
nodeDom.remove();
});
test('tags the dom with data-craft-hovered when the Craft hovered event is set (Layers panel hover sync)', () => {
mockNode.hovered = true;
render(<RenderNode render={rendered} />);
expect(nodeDom.hasAttribute('data-craft-hovered')).toBe(true);
container.remove();
nodeDom.remove();
});
test('removes data-craft-hovered once the hovered event clears', () => {
mockNode.hovered = true;
render(<RenderNode render={rendered} />);
expect(nodeDom.hasAttribute('data-craft-hovered')).toBe(true);
mockNode.hovered = false;
act(() => {
root.render(<RenderNode render={rendered} />);
});
expect(nodeDom.hasAttribute('data-craft-hovered')).toBe(false);
container.remove();
nodeDom.remove();
});
});
+116
View File
@@ -0,0 +1,116 @@
import React, { useCallback, useEffect, useRef } from 'react';
import { createPortal } from 'react-dom';
import { useEditor, useNode } from '@craftjs/core';
interface RenderNodeProps {
render: React.ReactElement;
}
/**
* Craft.js `<Editor onRender>` override -- wraps every node's render output.
* For the currently-selected node it portals a floating badge (component
* displayName + a "select parent" chevron) positioned over the node's real
* DOM element. Non-selected nodes (the overwhelming majority) and ROOT pass
* straight through as a Fragment, so this never touches layout, never
* appears in `toHtml` export (that walks the Craft node tree, not this
* portal), and doesn't wrap every node in extra DOM.
*
* It also imperatively tags each node's real DOM element with two
* editor-only data attributes (never part of `toHtml` export, which walks
* the Craft node tree, not the live DOM):
* - `data-craft-node`: set on actual Craft.js droppable containers
* (`node.data.isCanvas`, excluding ROOT). `editor.css`'s dashed "guide"
* outlines target this attribute instead of blanket tag selectors
* (div/section/header/...), so a component's own internal wrapper markup
* no longer picks up a guide outline it isn't a real drop target for.
* - `data-craft-hovered`: mirrors `node.events.hovered` (Craft's hover
* event set). Craft's own `connectors.connect()` (called by every
* component) wires a native mouseover/mouseleave listener to this event
* internally, so a plain mouse hover over any connected node sets it --
* this attribute is a real-mouse-hover canvas highlight. (The Layers
* panel's row-hover -> canvas-highlight sync, item 12, uses a sibling
* `data-layer-hovered` attribute written directly by LayersPanel.tsx
* instead of this event, since the action that would drive it here
* -- `actions.setNodeEvent` -- is stripped from the public `useEditor()`
* API at runtime.) editor.css matches both attributes for the same
* outline and suppresses both under `.guides-off`.
*/
export const RenderNode: React.FC<RenderNodeProps> = ({ render }) => {
const { actions } = useEditor();
const { id, isSelected, dom, name, parent, isCanvas, isHovered } = useNode((node) => ({
isSelected: node.events.selected,
isHovered: node.events.hovered,
dom: node.dom,
name: (node.data.props?.aiName as string) || node.data.displayName,
parent: node.data.parent,
isCanvas: node.data.isCanvas,
}));
const badgeRef = useRef<HTMLDivElement>(null);
const active = isSelected && id !== 'ROOT' && !!dom;
const updatePosition = useCallback(() => {
if (!dom || !badgeRef.current) return;
const rect = dom.getBoundingClientRect();
const badgeHeight = 22;
badgeRef.current.style.left = `${Math.max(rect.left, 0)}px`;
badgeRef.current.style.top = `${Math.max(rect.top - badgeHeight, 0)}px`;
}, [dom]);
useEffect(() => {
if (!active) return;
updatePosition();
window.addEventListener('resize', updatePosition);
document.addEventListener('scroll', updatePosition, true);
return () => {
window.removeEventListener('resize', updatePosition);
document.removeEventListener('scroll', updatePosition, true);
};
}, [active, updatePosition]);
useEffect(() => {
if (!dom) return;
if (isCanvas && id !== 'ROOT') {
dom.setAttribute('data-craft-node', '');
} else {
dom.removeAttribute('data-craft-node');
}
}, [dom, isCanvas, id]);
useEffect(() => {
if (!dom) return;
if (isHovered) {
dom.setAttribute('data-craft-hovered', '');
} else {
dom.removeAttribute('data-craft-hovered');
}
}, [dom, isHovered]);
if (!active) return <>{render}</>;
return (
<>
{render}
{createPortal(
<div ref={badgeRef} className="component-indicator" style={{ position: 'fixed' }}>
<span>{name}</span>
{parent && (
<button
type="button"
className="component-indicator-parent-btn"
title="Select parent"
aria-label={`Select parent of ${name}`}
onMouseDown={(e) => {
e.stopPropagation();
actions.selectNode(parent);
}}
>
<i className="fa fa-chevron-up" aria-hidden />
</button>
)}
</div>,
document.body
)}
</>
);
};
+29
View File
@@ -0,0 +1,29 @@
import { describe, test, expect, afterEach } from 'vitest';
import { getClipboardNodeId, setClipboardNodeId } from './clipboard';
describe('clipboard', () => {
afterEach(() => {
setClipboardNodeId(null);
});
test('starts empty', () => {
expect(getClipboardNodeId()).toBeNull();
});
test('set then get returns the stored node id', () => {
setClipboardNodeId('node-123');
expect(getClipboardNodeId()).toBe('node-123');
});
test('is a shared module-level store -- overwriting replaces the previous value', () => {
setClipboardNodeId('first');
setClipboardNodeId('second');
expect(getClipboardNodeId()).toBe('second');
});
test('can be cleared back to null', () => {
setClipboardNodeId('node-123');
setClipboardNodeId(null);
expect(getClipboardNodeId()).toBeNull();
});
});
+23
View File
@@ -0,0 +1,23 @@
/**
* Tiny shared clipboard for canvas node copy/paste.
*
* Both the context menu (right-click Copy/Paste) and the keyboard shortcuts
* hook (Ctrl/Cmd+C / Ctrl/Cmd+V) read and write this single module-level
* store, so copying a node via one entry point and pasting via the other
* behaves consistently instead of each maintaining its own clipboard.
*
* Deliberately not React state -- nothing in the UI needs to re-render
* reactively when the clipboard changes; consumers just read the current
* value at the moment they need it (on paste, or when a menu opens).
*/
let clipboardNodeId: string | null = null;
/** Returns the id of the node currently on the clipboard, or null if empty. */
export function getClipboardNodeId(): string | null {
return clipboardNodeId;
}
/** Sets (or clears, with `null`) the node id on the clipboard. */
export function setClipboardNodeId(nodeId: string | null): void {
clipboardNodeId = nodeId;
}
@@ -0,0 +1,259 @@
import { describe, test, expect, vi, beforeEach, afterEach } from 'vitest';
import React from 'react';
import { createRoot, Root } from 'react-dom/client';
import { act } from 'react-dom/test-utils';
import type { NodeTree, Node } from '@craftjs/core';
import { useKeyboardShortcuts } from './useKeyboardShortcuts';
import { getClipboardNodeId, setClipboardNodeId } from './clipboard';
/**
* Regression coverage: Ctrl/Cmd+V must run the copied subtree through
* `regenerateTreeIds` BEFORE handing it to `actions.addNodeTree`. Pasting
* with the source node's ORIGINAL ids re-inserted them into the live Craft.js
* node map, producing duplicate ids and corrupting the tree -- that bug is
* exactly what this suite guards against. Also covers the sibling-parent /
* ROOT-fallback targeting, the empty-clipboard no-op, and the existing
* input-focus guard.
*
* Mock pattern mirrors PageContext.pure-updaters.test.tsx /
* PageContext.slug.test.tsx: a fake `useEditor` exposing `query`/`actions`,
* mounted via a bare consumer component, with REAL `keydown` events
* dispatched on `document` (where useKeyboardShortcuts attaches its
* listener) to exercise the actual handler rather than calling it directly.
*
* `regenerateTreeIds` itself is NOT mocked -- we partially mock
* `../utils/craft-tree` to wrap the real implementation in a `vi.fn()` so we
* can assert it was called, while still getting genuinely fresh ids back.
*/
const addNodeTreeMock = vi.fn();
let selectedIds: string[] = [];
function makeNode(id: string, parent: string | null, children: string[] = []): Node {
return {
id,
data: {
props: {},
type: { resolvedName: 'Container' },
name: 'Container',
displayName: 'Container',
isCanvas: false,
parent,
linkedNodes: {},
nodes: children,
hidden: false,
},
info: {},
events: { selected: false, dragged: false, hovered: false },
dom: null,
related: {},
rules: {},
_hydrationTimestamp: 0,
} as unknown as Node;
}
// The tree `toNodeTree()` returns for the node that was Ctrl+C'd -- a root
// with one child, so regeneration has more than one id to remap.
const COPIED_TREE: NodeTree = {
rootNodeId: 'copied-root-1',
nodes: {
'copied-root-1': makeNode('copied-root-1', null, ['copied-child-1']),
'copied-child-1': makeNode('copied-child-1', 'copied-root-1'),
},
};
const nodeStore: Record<string, { data: { parent: string | null } }> = {
'selected-1': { data: { parent: 'parent-container-1' } },
ROOT: { data: { parent: null } },
'copied-root-1': { data: { parent: 'wherever-it-originally-lived' } },
};
function makeQueryNode(id: string) {
return {
get: () => nodeStore[id] ?? null,
toNodeTree: () => {
if (id !== 'copied-root-1') {
throw new Error(`unexpected toNodeTree() call for "${id}"`);
}
return COPIED_TREE;
},
};
}
vi.mock('@craftjs/core', () => ({
useEditor: () => ({
query: {
getEvent: () => ({ all: () => selectedIds }),
node: (id: string) => makeQueryNode(id),
},
actions: {
addNodeTree: addNodeTreeMock,
history: { undo: vi.fn(), redo: vi.fn() },
delete: vi.fn(),
clearEvents: vi.fn(),
},
}),
}));
// Partial mock: keep the real `regenerateTreeIds` implementation (so pasted
// trees genuinely get fresh ids) but wrap it in a spy so we can assert the
// handler actually calls it, rather than only inferring that from the
// output.
vi.mock('../utils/craft-tree', async (importOriginal) => {
const actual = await importOriginal<typeof import('../utils/craft-tree')>();
return {
...actual,
regenerateTreeIds: vi.fn(actual.regenerateTreeIds),
};
});
import { regenerateTreeIds } from '../utils/craft-tree';
const regenerateTreeIdsMock = vi.mocked(regenerateTreeIds);
let container: HTMLDivElement;
let root: Root;
function render(ui: React.ReactElement) {
container = document.createElement('div');
document.body.appendChild(container);
act(() => {
root = createRoot(container);
root.render(ui);
});
}
function unmount() {
act(() => {
root.unmount();
});
container.remove();
}
function pressKey(key: string, opts: Partial<KeyboardEventInit> = {}) {
act(() => {
document.dispatchEvent(
new KeyboardEvent('keydown', { key, ctrlKey: true, bubbles: true, cancelable: true, ...opts }),
);
});
}
const Consumer: React.FC = () => {
useKeyboardShortcuts();
return null;
};
beforeEach(() => {
selectedIds = [];
addNodeTreeMock.mockClear();
regenerateTreeIdsMock.mockClear();
setClipboardNodeId(null);
});
afterEach(() => {
setClipboardNodeId(null);
if (root) unmount();
});
describe('useKeyboardShortcuts: Ctrl/Cmd+C / Ctrl/Cmd+V', () => {
test('Ctrl+C copies the selected node id to the clipboard', () => {
render(<Consumer />);
selectedIds = ['selected-1'];
pressKey('c');
expect(getClipboardNodeId()).toBe('selected-1');
});
test('Ctrl+V pastes as a sibling of the selection (selected node`s data.parent) with FRESH ids', () => {
render(<Consumer />);
selectedIds = ['copied-root-1'];
pressKey('c');
expect(getClipboardNodeId()).toBe('copied-root-1');
selectedIds = ['selected-1'];
pressKey('v');
// regenerateTreeIds actually ran before the tree was handed to Craft.js.
expect(regenerateTreeIdsMock).toHaveBeenCalledTimes(1);
expect(regenerateTreeIdsMock).toHaveBeenCalledWith(COPIED_TREE);
expect(addNodeTreeMock).toHaveBeenCalledTimes(1);
const [pastedTree, targetParent] = addNodeTreeMock.mock.calls[0];
// Sibling of the current selection: selected-1's data.parent.
expect(targetParent).toBe('parent-container-1');
// The regression this guards: pasted ids must be fresh, never reuse the
// ids the copied node already occupies in the live Craft.js tree.
expect(pastedTree.rootNodeId).not.toBe(COPIED_TREE.rootNodeId);
const originalIds = new Set(Object.keys(COPIED_TREE.nodes));
const pastedIds = new Set(Object.keys(pastedTree.nodes));
expect(pastedIds.size).toBe(originalIds.size);
for (const id of pastedIds) {
expect(originalIds.has(id)).toBe(false);
}
});
test('Ctrl+V with selection at ROOT falls back to ROOT as the insertion parent', () => {
render(<Consumer />);
selectedIds = ['copied-root-1'];
pressKey('c');
selectedIds = ['ROOT'];
pressKey('v');
expect(addNodeTreeMock).toHaveBeenCalledTimes(1);
const [, targetParent] = addNodeTreeMock.mock.calls[0];
expect(targetParent).toBe('ROOT');
});
test('Ctrl+V with an empty clipboard is a no-op', () => {
render(<Consumer />);
selectedIds = ['selected-1'];
pressKey('v');
expect(addNodeTreeMock).not.toHaveBeenCalled();
expect(regenerateTreeIdsMock).not.toHaveBeenCalled();
});
test('shortcuts are ignored while focus is in an input element', () => {
const input = document.createElement('input');
document.body.appendChild(input);
input.focus();
expect(document.activeElement).toBe(input);
render(<Consumer />);
selectedIds = ['selected-1'];
pressKey('c');
expect(getClipboardNodeId()).toBeNull();
setClipboardNodeId('copied-root-1');
pressKey('v');
expect(addNodeTreeMock).not.toHaveBeenCalled();
input.remove();
});
test('shortcuts are ignored while focus is in a contentEditable element', () => {
// jsdom does not implement `HTMLElement.isContentEditable` (it always
// reports false regardless of the contentEditable attribute -- a known
// jsdom limitation), so a real contentEditable + focus() can't exercise
// this branch of the guard. Stub `document.activeElement` directly with
// a fake element that reports isContentEditable: true, matching what
// the guard (`isInputFocused` in useKeyboardShortcuts.ts) actually reads.
const fakeEditable = { tagName: 'DIV', isContentEditable: true } as unknown as Element;
const activeElementSpy = vi.spyOn(document, 'activeElement', 'get').mockReturnValue(fakeEditable);
render(<Consumer />);
selectedIds = ['selected-1'];
pressKey('c');
expect(getClipboardNodeId()).toBeNull();
activeElementSpy.mockRestore();
});
});
+40
View File
@@ -2,6 +2,7 @@ import { useEffect } from 'react';
import { useEditor } from '@craftjs/core'; import { useEditor } from '@craftjs/core';
import { findDeletableTarget } from '../utils/craft-helpers'; import { findDeletableTarget } from '../utils/craft-helpers';
import { regenerateTreeIds } from '../utils/craft-tree'; import { regenerateTreeIds } from '../utils/craft-tree';
import { getClipboardNodeId, setClipboardNodeId } from './clipboard';
function isInputFocused(): boolean { function isInputFocused(): boolean {
const el = document.activeElement; const el = document.activeElement;
@@ -85,6 +86,45 @@ export function useKeyboardShortcuts() {
return; return;
} }
// Ctrl+C: copy selected node id to the shared clipboard
if (ctrl && (e.key === 'c' || e.key === 'C')) {
e.preventDefault();
try {
const selected = query.getEvent('selected').all();
if (selected.length > 0 && selected[0] !== 'ROOT') {
setClipboardNodeId(selected[0]);
}
} catch (err) {
console.error('Copy failed:', err);
}
return;
}
// Ctrl+V: paste the clipboard node as a sibling of the current selection
if (ctrl && (e.key === 'v' || e.key === 'V')) {
e.preventDefault();
try {
const sourceId = getClipboardNodeId();
if (!sourceId || !query.node(sourceId).get()) return;
const selected = query.getEvent('selected').all();
if (selected.length === 0) return;
const selectedId = selected[0];
let targetParent = 'ROOT';
if (selectedId !== 'ROOT') {
const node = query.node(selectedId).get();
targetParent = node?.data?.parent || 'ROOT';
}
const tree = regenerateTreeIds(query.node(sourceId).toNodeTree());
actions.addNodeTree(tree, targetParent);
} catch (err) {
console.error('Paste failed:', err);
}
return;
}
// Escape: deselect all // Escape: deselect all
if (e.key === 'Escape') { if (e.key === 'Escape') {
e.preventDefault(); e.preventDefault();
+60
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.
@@ -87,4 +94,57 @@ describe('buildSavePayload', () => {
expect(payload.header_craft_state).toBe('STALE_HEADER'); expect(payload.header_craft_state).toBe('STALE_HEADER');
expect(payload.footer_craft_state).toBe('STALE_FOOTER'); expect(payload.footer_craft_state).toBe('STALE_FOOTER');
}); });
test('I-1: activePageId matches no page (dangling, e.g. original Home deleted then reload reset it to "home") -- live serialize still lands in pages_craft_state[0] / index.html, not lost', () => {
// pages[0] has id 'page_x' (the replacement landing page after the
// original 'home' was deleted); activePageId is stuck at the stale
// default 'home', which matches no entry in `pages`.
const pageX: PageData = { id: 'page_x', name: 'Home', slug: 'index', craftState: 'STORED_X' };
const p2: PageData = { id: 'p2', name: 'About', slug: 'about', craftState: 'STORED_ABOUT_2' };
const payload = buildSavePayload({
siteId: 1,
siteName: 'Test Site',
liveCraftState: 'LIVE_EDIT',
pages: [pageX, p2],
headerPage,
footerPage,
activePageId: 'home',
isEditingHeader: 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),
// not be silently dropped to only the legacy top-level fields.
expect(payload.pages_craft_state[0].craftState).toBe('LIVE_EDIT');
expect(payload.pages[0].filename).toBe('index.html');
// Top-level fields (legacy) should also reflect the live edit.
expect(payload.craft_state).toBe('LIVE_EDIT');
// The other page is untouched.
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">');
});
}); });
+53 -4
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,10 +50,24 @@ export function buildSavePayload(input: BuildSavePayloadInput) {
activePageId, activePageId,
isEditingHeader, isEditingHeader,
isEditingFooter, isEditingFooter,
headCode,
design,
} = input; } = input;
const isPageActive = !isEditingHeader && !isEditingFooter; const isPageActive = !isEditingHeader && !isEditingFooter;
// I-1 (data-loss): `activePageId` can go dangling -- e.g. the original
// Home page (id 'home') is deleted, its replacement gets a fresh id
// (`page_<ts>`), and a reload re-initializes `activePageId` back to the
// hardcoded default `'home'` (see PageContext's `useState('home')`) before
// `load()` has a chance to point it at the actually-restored page. If a
// real page IS active but matches no entry in `pages`, treat `pages[0]`
// (the landing page) as the active one so the live canvas serialization
// still reaches the index.html page slot / `pages_craft_state[0]` instead
// of only the legacy top-level `craft_state`/`html` fields.
const activePageIndex = isPageActive ? pages.findIndex((p) => p.id === activePageId) : -1;
const effectiveActivePageId = activePageIndex !== -1 ? activePageId : pages[0]?.id;
// Fresh header/footer state: the live canvas wins when that zone is the // Fresh header/footer state: the live canvas wins when that zone is the
// one currently being edited; otherwise fall back to the last-committed // one currently being edited; otherwise fall back to the last-committed
// stored state (updated on zone switch by PageContext's saveCurrentState). // stored state (updated on zone switch by PageContext's saveCurrentState).
@@ -115,7 +134,7 @@ export function buildSavePayload(input: BuildSavePayloadInput) {
const filename = i === 0 ? 'index.html' : page.slug + '.html'; const filename = i === 0 ? 'index.html' : page.slug + '.html';
let pageHtml = ''; let pageHtml = '';
if (isPageActive && page.id === activePageId) { if (isPageActive && page.id === effectiveActivePageId) {
// Active page: use the current canvas HTML (already exported above) // Active page: use the current canvas HTML (already exported above)
pageHtml = currentHtml; pageHtml = currentHtml;
} else if (page.craftState) { } else if (page.craftState) {
@@ -146,7 +165,7 @@ export function buildSavePayload(input: BuildSavePayloadInput) {
// reload the editor's clean-URL routing (.htaccess rewrite of /name → // reload the editor's clean-URL routing (.htaccess rewrite of /name →
// name.html) lines up with the file we just wrote (index.html). // name.html) lines up with the file we just wrote (index.html).
slug: i === 0 ? 'index' : page.slug, slug: i === 0 ? 'index' : page.slug,
craftState: (isPageActive && page.id === activePageId) ? liveCraftState : (page.craftState || null), craftState: (isPageActive && page.id === effectiveActivePageId) ? liveCraftState : (page.craftState || null),
})); }));
return { return {
@@ -161,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,
}; };
} }
@@ -177,7 +198,9 @@ export function useWhpApi() {
setHeaderCraftState, setHeaderCraftState,
setFooterCraftState, setFooterCraftState,
setPagesCraftState, setPagesCraftState,
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;
@@ -197,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`, {
@@ -208,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;
@@ -242,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'
@@ -271,10 +307,23 @@ export function useWhpApi() {
console.warn('Failed to load page state:', e); console.warn('Failed to load page state:', e);
} }
} }
// I-1 (data-loss): point activePageId at the page we just loaded
// into the canvas. Without this, activePageId stays at whatever it
// was initialized to (the hardcoded default 'home'), which goes
// dangling the moment the original Home page has been deleted and
// replaced (its replacement gets a fresh `page_<ts>` id) -- the next
// edit+save would then only reach the legacy top-level fields
// instead of the actual page slot. Only do this when a real page is
// being loaded, i.e. we're not currently mid-edit of the header/
// footer zone (switching zones is handled separately by switchPage).
if (!isEditingHeader && !isEditingFooter) {
setActivePageIdDirect(firstPage.id);
}
} }
} }
return data; return data;
}, [isWHP, whpConfig, actions, setHeaderCraftState, setFooterCraftState, setPagesCraftState]); }, [isWHP, whpConfig, actions, setHeaderCraftState, setFooterCraftState, setPagesCraftState, setActivePageIdDirect, isEditingHeader, isEditingFooter, updateDesign]);
const uploadAsset = useCallback( const uploadAsset = useCallback(
async (file: File) => { async (file: File) => {
+19 -6
View File
@@ -4,6 +4,7 @@ import { findDeletableTarget } from '../../utils/craft-helpers';
import { useSitesmithModal } from '../../state/SitesmithContext'; import { useSitesmithModal } from '../../state/SitesmithContext';
import { buildSitesmithTarget } from '../../utils/sitesmith-target'; import { buildSitesmithTarget } from '../../utils/sitesmith-target';
import { regenerateTreeIds } from '../../utils/craft-tree'; import { regenerateTreeIds } from '../../utils/craft-tree';
import { getClipboardNodeId, setClipboardNodeId } from '../../hooks/clipboard';
interface ContextMenuProps { interface ContextMenuProps {
visible: boolean; visible: boolean;
@@ -15,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;
@@ -32,7 +35,6 @@ export const ContextMenu: React.FC<ContextMenuProps> = ({
const { actions, query } = useEditor(); const { actions, query } = useEditor();
const { open: openSitesmith } = useSitesmithModal(); const { open: openSitesmith } = useSitesmithModal();
const menuRef = useRef<HTMLDivElement>(null); const menuRef = useRef<HTMLDivElement>(null);
const clipboardRef = useRef<string | null>(null);
// Close on click outside // Close on click outside
useEffect(() => { useEffect(() => {
@@ -80,7 +82,7 @@ export const ContextMenu: React.FC<ContextMenuProps> = ({
const copyNode = useCallback(() => { const copyNode = useCallback(() => {
if (!nodeId || nodeId === 'ROOT') return; if (!nodeId || nodeId === 'ROOT') return;
try { try {
clipboardRef.current = nodeId; setClipboardNodeId(nodeId);
} catch (e) { } catch (e) {
console.error('Copy failed:', e); console.error('Copy failed:', e);
} }
@@ -88,7 +90,7 @@ export const ContextMenu: React.FC<ContextMenuProps> = ({
}, [nodeId, onClose]); }, [nodeId, onClose]);
const pasteNode = useCallback(() => { const pasteNode = useCallback(() => {
const sourceId = clipboardRef.current; const sourceId = getClipboardNodeId();
if (!sourceId) { if (!sourceId) {
onClose(); onClose();
return; return;
@@ -189,48 +191,56 @@ 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,
}, },
{ {
label: 'Duplicate', label: 'Duplicate',
icon: 'clone',
shortcut: 'Ctrl+D', shortcut: 'Ctrl+D',
action: duplicate, action: duplicate,
disabled: isRoot, disabled: isRoot,
}, },
{ {
label: 'Copy', label: 'Copy',
icon: 'files-o',
shortcut: 'Ctrl+C', shortcut: 'Ctrl+C',
action: copyNode, action: copyNode,
disabled: isRoot, disabled: isRoot,
}, },
{ {
label: 'Paste', label: 'Paste',
icon: 'clipboard',
shortcut: 'Ctrl+V', shortcut: 'Ctrl+V',
action: pasteNode, action: pasteNode,
disabled: !clipboardRef.current, disabled: !getClipboardNodeId(),
dividerAfter: true, dividerAfter: true,
}, },
{ {
label: 'Move Up', label: 'Move Up',
icon: 'arrow-up',
action: moveUp, action: moveUp,
disabled: isRoot, disabled: isRoot,
}, },
{ {
label: 'Move Down', label: 'Move Down',
icon: 'arrow-down',
action: moveDown, action: moveDown,
disabled: isRoot, disabled: isRoot,
}, },
{ {
label: 'Select Parent', label: 'Select Parent',
icon: 'level-up',
action: selectParent, action: selectParent,
disabled: isRoot, disabled: isRoot,
dividerAfter: true, dividerAfter: true,
}, },
{ {
label: 'Delete', label: 'Delete',
icon: 'trash',
shortcut: 'Del', shortcut: 'Del',
action: deleteNode, action: deleteNode,
danger: true, danger: true,
@@ -291,7 +301,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',
+155 -24
View File
@@ -1,14 +1,74 @@
import React, { useCallback } from 'react'; import React, { useCallback, useEffect, useRef } from 'react';
import { useEditor } from '@craftjs/core'; import { useEditor } from '@craftjs/core';
import { clickableProps } from '../../utils/a11y'; import { clickableProps } from '../../utils/a11y';
/**
* Per-type icon lookup keyed by the component's `craft.displayName` (the
* same human label `LayerNode` already resolves and shows as the row text).
* Mirrors the icon choices in `BlocksPanel.tsx` where a block exists for the
* type; the remaining entries (Navbar, Container, Section, Columns,
* Background Section, Map, Video, Image, form fields, and the richer
* section blocks not draggable from the Basic/Sections categories) get a
* sensible FA4 icon of their own. Unknown/future types fall back to
* `DEFAULT_ICON`.
*/
const TYPE_ICONS: Record<string, string> = {
// Layout
Container: 'fa-square-o',
Section: 'fa-window-maximize',
Columns: 'fa-th-large',
'Background Section': 'fa-picture-o',
// Basic
Heading: 'fa-header',
Text: 'fa-paragraph',
Button: 'fa-square',
Logo: 'fa-bookmark',
Menu: 'fa-bars',
Navbar: 'fa-bars',
Footer: 'fa-window-minimize',
Divider: 'fa-minus',
Spacer: 'fa-arrows-v',
Icon: 'fa-star',
'Star Rating': 'fa-star-half-o',
'Social Links': 'fa-share-alt',
'Search Bar': 'fa-search',
HTML: 'fa-code',
// Media
Image: 'fa-image',
Video: 'fa-play-circle',
Map: 'fa-map-marker',
// Sections
Hero: 'fa-star-o',
'Features Grid': 'fa-th-large',
'CTA Section': 'fa-bullhorn',
'Call to Action': 'fa-bullhorn',
Countdown: 'fa-clock-o',
Testimonials: 'fa-quote-left',
'Content Slider': 'fa-sliders',
'Number Counter': 'fa-sort-numeric-asc',
Accordion: 'fa-list',
Tabs: 'fa-folder-o',
'Pricing Table': 'fa-usd',
Gallery: 'fa-th',
// Forms
Form: 'fa-wpforms',
Input: 'fa-i-cursor',
Textarea: 'fa-align-left',
'Submit Button': 'fa-paper-plane',
'Contact Form': 'fa-envelope',
'Subscribe Form': 'fa-paper-plane',
};
const DEFAULT_ICON = 'fa-cube';
const ROOT_ICON = 'fa-desktop';
interface LayerNodeProps { interface LayerNodeProps {
nodeId: string; nodeId: string;
depth: number; depth: number;
} }
const LayerNode: React.FC<LayerNodeProps> = ({ nodeId, depth }) => { const LayerNode: React.FC<LayerNodeProps> = ({ nodeId, depth }) => {
const { node, selectedId, actions } = useEditor((state) => { const { node, selectedId, actions, query } = useEditor((state) => {
const n = state.nodes[nodeId]; const n = state.nodes[nodeId];
const selectedIds = state.events.selected; const selectedIds = state.events.selected;
const selId = selectedIds ? Array.from(selectedIds)[0] : null; const selId = selectedIds ? Array.from(selectedIds)[0] : null;
@@ -18,28 +78,76 @@ const LayerNode: React.FC<LayerNodeProps> = ({ nodeId, depth }) => {
}; };
}); });
const isSelected = selectedId === nodeId;
const handleActivate = useCallback(() => { const handleActivate = useCallback(() => {
actions.selectNode(nodeId); actions.selectNode(nodeId);
}, [actions, nodeId]); }, [actions, nodeId]);
// Layers-panel <-> canvas hover sync (Item 12): tags the target node's own
// DOM element (found via `query`) with `data-layer-hovered` directly.
// `RenderNode.tsx` mirrors Craft's *own* `hovered` node event onto
// `data-craft-hovered` for the (separate, mouse-driven) canvas hover
// outline -- editor.css's rule matches both attributes. This panel can't
// reuse that path: `setNodeEvent` is a private action that `useEditor()`
// strips from its `actions` return at runtime (see
// @craftjs/core/dist/esm's `xe()` action stripper, not just its TypeScript
// type), so calling it here throws. Writing our own attribute directly is
// a supported, version-stable way to get the same highlight.
const hoveredDomRef = useRef<HTMLElement | null>(null);
const handleMouseEnter = useCallback(
(e: React.MouseEvent<HTMLElement> | React.FocusEvent<HTMLElement>) => {
const dom = query.node(nodeId).get()?.dom;
if (dom) {
dom.setAttribute('data-layer-hovered', '');
hoveredDomRef.current = dom;
}
if (!isSelected) {
(e.currentTarget as HTMLElement).style.background = 'var(--color-bg-hover)';
}
},
[query, nodeId, isSelected],
);
const handleMouseLeave = useCallback(
(e: React.MouseEvent<HTMLElement> | React.FocusEvent<HTMLElement>) => {
hoveredDomRef.current?.removeAttribute('data-layer-hovered');
hoveredDomRef.current = null;
if (!isSelected) {
(e.currentTarget as HTMLElement).style.background = 'transparent';
}
},
[isSelected],
);
// Safety net: if this row unmounts (e.g. the user switches away from the
// Layers tab, or the tree re-shuffles) while still hovered, clear the
// attribute so it doesn't get stuck highlighted on the canvas forever.
useEffect(() => {
return () => {
hoveredDomRef.current?.removeAttribute('data-layer-hovered');
hoveredDomRef.current = null;
};
}, []);
if (!node) return null; if (!node) return null;
const isSelected = selectedId === nodeId;
const nodeType = node.data.type;
const resolvedName = typeof nodeType === 'object' && nodeType !== null && 'resolvedName' in nodeType
? (nodeType as any).resolvedName
: typeof nodeType === 'string' ? nodeType : undefined;
const displayName = (node.data.props?.aiName as string) || node.data.displayName || (node.data.type as any)?.resolvedName || 'Node'; const displayName = (node.data.props?.aiName as string) || node.data.displayName || (node.data.type as any)?.resolvedName || 'Node';
const childNodeIds: string[] = node.data.nodes || []; const childNodeIds: string[] = node.data.nodes || [];
const linkedNodeIds: string[] = Object.values(node.data.linkedNodes || {}) as string[]; const linkedNodeIds: string[] = Object.values(node.data.linkedNodes || {}) as string[];
const allChildren = [...childNodeIds, ...linkedNodeIds]; const allChildren = [...childNodeIds, ...linkedNodeIds];
const isRoot = nodeId === 'ROOT'; const isRoot = nodeId === 'ROOT';
const icon = isRoot ? ROOT_ICON : TYPE_ICONS[displayName] || DEFAULT_ICON;
return ( return (
<div> <div>
<div <div
{...clickableProps(handleActivate)} {...clickableProps(handleActivate)}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
style={{ style={{
position: 'relative',
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
padding: '5px 8px', padding: '5px 8px',
@@ -56,30 +164,53 @@ const LayerNode: React.FC<LayerNodeProps> = ({ nodeId, depth }) => {
textOverflow: 'ellipsis', textOverflow: 'ellipsis',
userSelect: 'none', userSelect: 'none',
}} }}
onMouseEnter={(e) => { onFocus={handleMouseEnter}
if (!isSelected) { onBlur={handleMouseLeave}
(e.currentTarget as HTMLElement).style.background = 'var(--color-bg-hover)';
}
}}
onMouseLeave={(e) => {
if (!isSelected) {
(e.currentTarget as HTMLElement).style.background = 'transparent';
}
}}
> >
{/* Indentation indicator */} {/* Indent guides: one vertical line per ancestor depth level,
{allChildren.length > 0 && ( aligned under each ancestor's disclosure/icon column so nested
<span style={{ marginRight: 4, fontSize: 8, color: 'var(--color-text-dim)' }}> rows read as a tree instead of a flat, ever-further-indented list. */}
{Array.from({ length: depth }).map((_, level) => (
<span
key={level}
aria-hidden="true"
style={{
position: 'absolute',
left: `${8 + level * 16 + 4}px`,
top: 0,
bottom: 0,
width: 1,
background: 'var(--color-border)',
}}
/>
))}
{/* Indentation/disclosure indicator */}
{allChildren.length > 0 ? (
<span style={{ marginRight: 4, fontSize: 8, color: 'var(--color-text-dim)', flexShrink: 0 }}>
&#9660; &#9660;
</span> </span>
)} ) : (
{allChildren.length === 0 && ( <span style={{ marginRight: 4, fontSize: 8, color: 'transparent', flexShrink: 0 }}>
<span style={{ marginRight: 4, fontSize: 8, color: 'transparent' }}>
&#9660; &#9660;
</span> </span>
)} )}
{/* Component type icon and name */} {/* Component type icon */}
<i
className={`fa ${icon}`}
aria-hidden="true"
style={{
marginRight: 6,
fontSize: 11,
width: 12,
textAlign: 'center',
flexShrink: 0,
color: isSelected ? 'var(--color-accent)' : 'var(--color-text-dim)',
}}
/>
{/* Name */}
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis' }}> <span style={{ overflow: 'hidden', textOverflow: 'ellipsis' }}>
{isRoot ? 'Canvas (Root)' : displayName} {isRoot ? 'Canvas (Root)' : displayName}
</span> </span>
+47 -59
View File
@@ -58,18 +58,23 @@ export const PagesPanel: React.FC = () => {
.replace(/-+/g, '-'); .replace(/-+/g, '-');
}; };
/* ---------- Zone button style ---------- */ /* ---------- Zone row style ----------
const zoneButtonStyle = (isActive: boolean): React.CSSProperties => ({ * Same neutral surface language as the page-list rows below (Item 11) --
* header/footer are just two more items in the tree, not a louder,
* differently-colored category. The active/editing state reuses the same
* accent-outline treatment the page list already uses for the active page,
* so there's one consistent "this is what's currently open" affordance. */
const zoneRowStyle = (isActive: boolean): React.CSSProperties => ({
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
gap: 8, gap: 8,
width: '100%', width: '100%',
padding: '10px 12px', padding: '7px 10px',
fontSize: 12, fontSize: 12,
fontWeight: 600, fontWeight: isActive ? 600 : 500,
color: isActive ? '#f59e0b' : '#fbbf24', color: isActive ? 'var(--color-accent)' : 'var(--color-text-muted)',
background: isActive ? 'rgba(245, 158, 11, 0.15)' : 'rgba(245, 158, 11, 0.06)', background: isActive ? 'var(--color-accent-subtle)' : 'var(--color-bg-elevated)',
border: `1px solid ${isActive ? 'rgba(245, 158, 11, 0.5)' : 'rgba(245, 158, 11, 0.2)'}`, border: `1px solid ${isActive ? 'var(--color-accent)' : 'var(--color-border)'}`,
borderRadius: 'var(--radius-md)', borderRadius: 'var(--radius-md)',
cursor: 'pointer', cursor: 'pointer',
transition: 'all var(--transition-fast)', transition: 'all var(--transition-fast)',
@@ -78,57 +83,35 @@ export const PagesPanel: React.FC = () => {
return ( return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}> <div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{/* Header/Footer zone buttons */} {/* Header/Footer zone rows */}
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, marginBottom: 8 }}> <div style={{ display: 'flex', flexDirection: 'column', gap: 6, marginBottom: 8 }}>
<button <button
onClick={editHeader} onClick={editHeader}
style={zoneButtonStyle(isEditingHeader)} className="zone-row"
style={zoneRowStyle(isEditingHeader)}
title="Appears on all pages"
> >
<i className="fa fa-window-maximize" style={{ fontSize: 13 }} /> <i className="fa fa-window-maximize" style={{ fontSize: 12, width: 14, textAlign: 'center', flexShrink: 0 }} />
<div style={{ flex: 1 }}> <span style={{ flex: 1 }}>Header</span>
<div>Edit Header</div> {isEditingHeader ? (
<div style={{ fontSize: 10, opacity: 0.7, fontWeight: 400, marginTop: 1 }}> <i className="fa fa-check-circle" style={{ fontSize: 11 }} aria-hidden />
Appears on all pages ) : (
</div> <i className="fa fa-pencil zone-row-pencil" style={{ fontSize: 11 }} aria-hidden />
</div>
{isEditingHeader && (
<span style={{
fontSize: 9,
fontWeight: 700,
textTransform: 'uppercase',
letterSpacing: '0.5px',
background: 'rgba(245, 158, 11, 0.25)',
padding: '2px 6px',
borderRadius: 'var(--radius-sm)',
}}>
Editing
</span>
)} )}
</button> </button>
<button <button
onClick={editFooter} onClick={editFooter}
style={zoneButtonStyle(isEditingFooter)} className="zone-row"
style={zoneRowStyle(isEditingFooter)}
title="Appears on all pages"
> >
<i className="fa fa-window-minimize" style={{ fontSize: 13 }} /> <i className="fa fa-window-minimize" style={{ fontSize: 12, width: 14, textAlign: 'center', flexShrink: 0 }} />
<div style={{ flex: 1 }}> <span style={{ flex: 1 }}>Footer</span>
<div>Edit Footer</div> {isEditingFooter ? (
<div style={{ fontSize: 10, opacity: 0.7, fontWeight: 400, marginTop: 1 }}> <i className="fa fa-check-circle" style={{ fontSize: 11 }} aria-hidden />
Appears on all pages ) : (
</div> <i className="fa fa-pencil zone-row-pencil" style={{ fontSize: 11 }} aria-hidden />
</div>
{isEditingFooter && (
<span style={{
fontSize: 9,
fontWeight: 700,
textTransform: 'uppercase',
letterSpacing: '0.5px',
background: 'rgba(245, 158, 11, 0.25)',
padding: '2px 6px',
borderRadius: 'var(--radius-sm)',
}}>
Editing
</span>
)} )}
</button> </button>
</div> </div>
@@ -328,18 +311,21 @@ export const PagesPanel: React.FC = () => {
title="This is the landing page — published as the root URL (index.html)" title="This is the landing page — published as the root URL (index.html)"
style={{ style={{
fontSize: 9, fontSize: 9,
fontWeight: 700, fontWeight: 600,
textTransform: 'uppercase', letterSpacing: '0.3px',
letterSpacing: '0.5px', color: 'var(--color-text-muted)',
color: '#fbbf24', background: 'transparent',
background: 'rgba(245, 158, 11, 0.15)', border: '1px solid var(--color-border-light)',
border: '1px solid rgba(245, 158, 11, 0.35)',
padding: '1px 5px', padding: '1px 5px',
borderRadius: 'var(--radius-sm)', borderRadius: 'var(--radius-sm)',
flexShrink: 0, flexShrink: 0,
display: 'inline-flex',
alignItems: 'center',
gap: 4,
}} }}
> >
<i className="fa fa-home" style={{ marginRight: 3 }} />Landing <i className="fa fa-home" aria-hidden="true" style={{ fontSize: 8, color: 'inherit' }} />
Landing
</span> </span>
)} )}
</div> </div>
@@ -359,7 +345,8 @@ export const PagesPanel: React.FC = () => {
> >
<button <button
onClick={() => startEditing(page)} onClick={() => startEditing(page)}
title="Rename" data-tooltip="Rename"
aria-label={`Rename ${page.name}`}
style={{ style={{
width: 24, width: 24,
height: 24, height: 24,
@@ -374,12 +361,13 @@ export const PagesPanel: React.FC = () => {
cursor: 'pointer', cursor: 'pointer',
}} }}
> >
&#9998; <i className="fa fa-pencil" aria-hidden="true" />
</button> </button>
{pages.length > 1 && !isLanding && ( {pages.length > 1 && !isLanding && (
<button <button
onClick={() => setDeleteConfirmId(page.id)} onClick={() => setDeleteConfirmId(page.id)}
title="Delete" data-tooltip="Delete"
aria-label={`Delete ${page.name}`}
style={{ style={{
width: 24, width: 24,
height: 24, height: 24,
@@ -394,7 +382,7 @@ export const PagesPanel: React.FC = () => {
cursor: 'pointer', cursor: 'pointer',
}} }}
> >
&#10005; <i className="fa fa-trash" aria-hidden="true" />
</button> </button>
)} )}
</div> </div>
+3 -11
View File
@@ -1,6 +1,5 @@
import React from 'react'; import React from 'react';
import { useEditor } from '@craftjs/core'; import { useEditor } from '@craftjs/core';
import { componentResolver } from '../../components/resolver';
import { SiteDesignPanel } from './SiteDesignPanel'; import { SiteDesignPanel } from './SiteDesignPanel';
import { useSitesmithModal } from '../../state/SitesmithContext'; import { useSitesmithModal } from '../../state/SitesmithContext';
import { buildSitesmithTarget } from '../../utils/sitesmith-target'; import { buildSitesmithTarget } from '../../utils/sitesmith-target';
@@ -31,27 +30,21 @@ import {
================================================================ */ ================================================================ */
export const GuidedStyles: React.FC = () => { export const GuidedStyles: React.FC = () => {
const resolverMap = componentResolver as Record<string, any>;
const { open: openSitesmith } = useSitesmithModal(); const { open: openSitesmith } = useSitesmithModal();
const { query } = useEditor(); const { query } = useEditor();
const { selected, selectedType, nodeProps, resolvedName } = useEditor((state) => { const { selected, selectedType, nodeProps } = useEditor((state) => {
const currentNodeId = state.events.selected const currentNodeId = state.events.selected
? Array.from(state.events.selected)[0] ? Array.from(state.events.selected)[0]
: undefined; : undefined;
let selectedType: string | null = null; let selectedType: string | null = null;
let nodeProps: Record<string, any> = {}; let nodeProps: Record<string, any> = {};
let resolvedName: string | null = null;
if (currentNodeId) { if (currentNodeId) {
const node = state.nodes[currentNodeId]; const node = state.nodes[currentNodeId];
if (node) { if (node) {
selectedType = node.data.displayName || node.data.name || null; selectedType = node.data.displayName || node.data.name || null;
nodeProps = node.data.props || {}; nodeProps = node.data.props || {};
const nodeType = node.data.type as any;
if (nodeType && typeof nodeType === 'object' && nodeType.resolvedName) {
resolvedName = nodeType.resolvedName;
}
} }
} }
@@ -59,7 +52,6 @@ export const GuidedStyles: React.FC = () => {
selected: currentNodeId || null, selected: currentNodeId || null,
selectedType, selectedType,
nodeProps, nodeProps,
resolvedName,
}; };
}); });
@@ -75,8 +67,8 @@ export const GuidedStyles: React.FC = () => {
const isButton = /^button$/i.test(typeName); const isButton = /^button$/i.test(typeName);
const isImage = /^image$/i.test(typeName); const isImage = /^image$/i.test(typeName);
const isBgSection = /^background section$/i.test(typeName); const isBgSection = /^background section$/i.test(typeName);
const isContainer = /^container$|^section$|^columns$|^header zone$|^footer zone$/i.test(typeName); const isContainer = /^container$|^section$|^columns$/i.test(typeName);
const isHero = /hero/i.test(typeName); const isHero = /^hero/i.test(typeName);
const isNav = /^menu$|^logo$|^navbar$|^footer$/i.test(typeName); const isNav = /^menu$|^logo$|^navbar$|^footer$/i.test(typeName);
const isMedia = /^video$|^gallery$|^map$|^content slider$/i.test(typeName); const isMedia = /^video$|^gallery$|^map$|^content slider$/i.test(typeName);
const isForm = /^form$|^input$|^textarea$|^subscribe|^contact form$|^submit button$|^search bar$/i.test(typeName); const isForm = /^form$|^input$|^textarea$|^subscribe|^contact form$|^submit button$|^search bar$/i.test(typeName);
@@ -0,0 +1,127 @@
import React from 'react';
import { useEditor } from '@craftjs/core';
import { CollapsibleSection, ArrayPropEditor, smallInputStyle } from './shared';
/* ---------- Shared array-item field editor ----------
Extracted from SectionTypePanel and GenericPropsEditor, which both had a
near-byte-identical per-item field renderer for generic array props
(features/items/plans/testimonials/etc.). Infers an input type per field:
boolean -> checkbox, number -> number, /color/ -> color swatch, long
string -> textarea, else text. Fields are derived from Object.keys(items[0]).
Callers keep their own special-casing (e.g. SectionTypePanel routes
key === 'features' to FeaturesEditor instead of using this component). */
export const ArrayItemFieldsEditor: React.FC<{ selectedId: string; propKey: string; items: any[] }> = ({
selectedId, propKey, items,
}) => {
const { actions } = useEditor();
const arrayItems = items;
const sampleItem = arrayItems[0] || {};
const itemFields = typeof sampleItem === 'object' && sampleItem !== null ? Object.keys(sampleItem) : [];
return (
<CollapsibleSection title={propKey.replace(/([A-Z])/g, ' $1').trim()}>
<ArrayPropEditor
selectedId={selectedId}
propKey={propKey}
items={arrayItems}
renderItem={(item: any, index: number) => {
if (typeof item !== 'object' || item === null) {
return (
<input
type="text"
value={String(item)}
onChange={(e) => {
actions.setProp(selectedId, (props: any) => {
const updated = [...(props[propKey] || [])];
updated[index] = e.target.value;
props[propKey] = updated;
});
}}
style={smallInputStyle}
/>
);
}
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
{itemFields.map((field) => {
const fieldVal = item[field];
if (typeof fieldVal === 'boolean') {
return (
<label key={field} style={{ fontSize: 10, color: '#71717a', display: 'flex', alignItems: 'center', gap: 4, cursor: 'pointer' }}>
<input type="checkbox" checked={fieldVal} onChange={(e) => {
actions.setProp(selectedId, (props: any) => {
const updated = [...(props[propKey] || [])];
updated[index] = { ...updated[index], [field]: e.target.checked };
props[propKey] = updated;
});
}} />
{field}
</label>
);
}
if (typeof fieldVal === 'number') {
return (
<div key={field}>
<label style={{ fontSize: 9, color: '#52525b', textTransform: 'capitalize' }}>{field}</label>
<input type="number" value={fieldVal} onChange={(e) => {
actions.setProp(selectedId, (props: any) => {
const updated = [...(props[propKey] || [])];
updated[index] = { ...updated[index], [field]: parseFloat(e.target.value) || 0 };
props[propKey] = updated;
});
}} style={smallInputStyle} />
</div>
);
}
// color fields
if (/color/i.test(field) && typeof fieldVal === 'string') {
return (
<div key={field} style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
<label style={{ fontSize: 9, color: '#52525b', textTransform: 'capitalize', width: 50 }}>{field}</label>
<input type="color" value={fieldVal || '#000000'} onChange={(e) => {
actions.setProp(selectedId, (props: any) => {
const updated = [...(props[propKey] || [])];
updated[index] = { ...updated[index], [field]: e.target.value };
props[propKey] = updated;
});
}} style={{ width: 24, height: 20, border: 'none', cursor: 'pointer', background: 'none', padding: 0 }} />
</div>
);
}
// long text
const strVal = String(fieldVal ?? '');
const isLongField = strVal.length > 50 || field === 'description' || field === 'text' || field === 'content';
return (
<div key={field}>
<label style={{ fontSize: 9, color: '#52525b', textTransform: 'capitalize' }}>{field}</label>
{isLongField ? (
<textarea value={strVal} onChange={(e) => {
actions.setProp(selectedId, (props: any) => {
const updated = [...(props[propKey] || [])];
updated[index] = { ...updated[index], [field]: e.target.value };
props[propKey] = updated;
});
}} rows={2} style={{ ...smallInputStyle, resize: 'vertical' }} />
) : (
<input type="text" value={strVal} onChange={(e) => {
actions.setProp(selectedId, (props: any) => {
const updated = [...(props[propKey] || [])];
updated[index] = { ...updated[index], [field]: e.target.value };
props[propKey] = updated;
});
}} style={smallInputStyle} />
)}
</div>
);
})}
</div>
);
}}
emptyItem={typeof sampleItem === 'object' && sampleItem !== null
? Object.fromEntries(itemFields.map((f) => [f, typeof sampleItem[f] === 'number' ? 0 : typeof sampleItem[f] === 'boolean' ? false : '']))
: ''
}
/>
</CollapsibleSection>
);
};
@@ -1,5 +1,4 @@
import React, { useCallback } from 'react'; import React from 'react';
import { useEditor } from '@craftjs/core';
import { import {
BG_COLORS, BG_COLORS,
SPACING_PRESETS, SPACING_PRESETS,
@@ -15,22 +14,13 @@ import {
labelStyle, labelStyle,
inputStyle, inputStyle,
sectionGap, sectionGap,
useNodeProp,
} from './shared'; } from './shared';
import { AssetPicker } from '../../../ui/AssetPicker'; import { AssetPicker } from '../../../ui/AssetPicker';
/* ---------- BACKGROUND SECTION ---------- */ /* ---------- BACKGROUND SECTION ---------- */
export const BackgroundSectionStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeProps }) => { export const BackgroundSectionStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeProps }) => {
const { actions } = useEditor(); const { setProp, setPropStyle } = useNodeProp(selectedId);
const setProp = useCallback((key: string, value: any) => {
actions.setProp(selectedId, (props: any) => { props[key] = value; });
}, [actions, selectedId]);
const setPropStyle = useCallback((key: string, value: string) => {
actions.setProp(selectedId, (props: any) => {
props.style = { ...props.style, [key]: value };
});
}, [actions, selectedId]);
const style = nodeProps.style || {}; const style = nodeProps.style || {};
@@ -12,6 +12,7 @@ import {
PresetButtonGrid, PresetButtonGrid,
TextInputField, TextInputField,
autoTextColor, autoTextColor,
useNodeProp,
} from './shared'; } from './shared';
/* ---------- BUTTON ---------- */ /* ---------- BUTTON ---------- */
@@ -19,14 +20,7 @@ export const ButtonStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodePr
const { actions } = useEditor(); const { actions } = useEditor();
const style: CSSProperties = nodeProps.style || {}; const style: CSSProperties = nodeProps.style || {};
const setPropStyle = useCallback( const { setPropStyle } = useNodeProp(selectedId);
(property: string, value: string) => {
actions.setProp(selectedId, (props: any) => {
props.style = { ...props.style, [property]: value };
});
},
[actions, selectedId],
);
const setButtonColor = useCallback( const setButtonColor = useCallback(
(bgColor: string) => { (bgColor: string) => {
@@ -1,5 +1,4 @@
import React, { useCallback, CSSProperties } from 'react'; import React, { CSSProperties } from 'react';
import { useEditor } from '@craftjs/core';
import { import {
BG_COLORS, BG_COLORS,
SPACING_PRESETS, SPACING_PRESETS,
@@ -14,28 +13,14 @@ import {
labelStyle, labelStyle,
inputStyle, inputStyle,
sectionGap, sectionGap,
useNodeProp,
} from './shared'; } from './shared';
/* ---------- CONTAINER / SECTION ---------- */ /* ---------- CONTAINER / SECTION ---------- */
export const ContainerStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeProps }) => { export const ContainerStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeProps }) => {
const { actions } = useEditor();
const style: CSSProperties = nodeProps.style || {}; const style: CSSProperties = nodeProps.style || {};
const setPropStyle = useCallback( const { setProp, setPropStyle } = useNodeProp(selectedId);
(property: string, value: string) => {
actions.setProp(selectedId, (props: any) => {
props.style = { ...props.style, [property]: value };
});
},
[actions, selectedId],
);
const setProp = useCallback(
(key: string, value: string) => {
actions.setProp(selectedId, (props: any) => { props[key] = value; });
},
[actions, selectedId],
);
return ( return (
<> <>
@@ -1,5 +1,4 @@
import React, { useCallback } from 'react'; import React from 'react';
import { useEditor } from '@craftjs/core';
import { import {
BG_COLORS, BG_COLORS,
SPACING_PRESETS, SPACING_PRESETS,
@@ -15,21 +14,12 @@ import {
inputStyle, inputStyle,
btnActiveStyle, btnActiveStyle,
sectionGap, sectionGap,
useNodeProp,
} from './shared'; } from './shared';
/* ---------- FORM ---------- */ /* ---------- FORM ---------- */
export const FormStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeProps }) => { export const FormStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeProps }) => {
const { actions } = useEditor(); const { setProp, setPropStyle } = useNodeProp(selectedId);
const setProp = useCallback((key: string, value: any) => {
actions.setProp(selectedId, (props: any) => { props[key] = value; });
}, [actions, selectedId]);
const setPropStyle = useCallback((key: string, value: string) => {
actions.setProp(selectedId, (props: any) => {
props.style = { ...props.style, [key]: value };
});
}, [actions, selectedId]);
const style = nodeProps.style || {}; const style = nodeProps.style || {};
@@ -1,5 +1,4 @@
import React, { useCallback } from 'react'; import React from 'react';
import { useEditor } from '@craftjs/core';
import { import {
TEXT_COLORS, TEXT_COLORS,
BG_COLORS, BG_COLORS,
@@ -12,30 +11,20 @@ import {
PresetButtonGrid, PresetButtonGrid,
CollapsibleSection, CollapsibleSection,
ColorPickerField, ColorPickerField,
ArrayPropEditor,
labelStyle, labelStyle,
inputStyle, inputStyle,
smallInputStyle,
sectionGap, sectionGap,
useNodeProp,
} from './shared'; } from './shared';
import { ArrayItemFieldsEditor } from './ArrayItemFields';
/* ---------- SMART GENERIC PROPS EDITOR (Fallback) ---------- */ /* ---------- SMART GENERIC PROPS EDITOR (Fallback) ---------- */
export const GenericPropsEditor: React.FC<{ selectedId: string; nodeProps: Record<string, any>; typeName: string }> = ({ export const GenericPropsEditor: React.FC<{ selectedId: string; nodeProps: Record<string, any>; typeName: string }> = ({
selectedId, nodeProps, typeName, selectedId, nodeProps, typeName,
}) => { }) => {
const { actions } = useEditor();
const SKIP_PROPS = new Set(['style', 'children', 'cssId', 'cssClass']); const SKIP_PROPS = new Set(['style', 'children', 'cssId', 'cssClass']);
const setPropValue = useCallback((key: string, value: any) => { const { setProp: setPropValue, setPropStyle: setStyleValue } = useNodeProp(selectedId);
actions.setProp(selectedId, (props: any) => { props[key] = value; });
}, [actions, selectedId]);
const setStyleValue = useCallback((key: string, value: string) => {
actions.setProp(selectedId, (props: any) => {
props.style = { ...props.style, [key]: value };
});
}, [actions, selectedId]);
// Categorize all props // Categorize all props
const allProps = Object.entries(nodeProps).filter(([key]) => !SKIP_PROPS.has(key)); const allProps = Object.entries(nodeProps).filter(([key]) => !SKIP_PROPS.has(key));
@@ -105,116 +94,9 @@ export const GenericPropsEditor: React.FC<{ selectedId: string; nodeProps: Recor
)} )}
{/* Array props */} {/* Array props */}
{arrayProps.map(([key, items]) => { {arrayProps.map(([key, items]) => (
const arrayItems = items as any[]; <ArrayItemFieldsEditor key={key} selectedId={selectedId} propKey={key} items={items as any[]} />
const sampleItem = arrayItems[0] || {}; ))}
const itemFields = typeof sampleItem === 'object' && sampleItem !== null ? Object.keys(sampleItem) : [];
return (
<CollapsibleSection key={key} title={key.replace(/([A-Z])/g, ' $1').trim()}>
<ArrayPropEditor
selectedId={selectedId}
propKey={key}
items={arrayItems}
renderItem={(item: any, index: number) => {
if (typeof item !== 'object' || item === null) {
return (
<input
type="text"
value={String(item)}
onChange={(e) => {
actions.setProp(selectedId, (props: any) => {
const updated = [...(props[key] || [])];
updated[index] = e.target.value;
props[key] = updated;
});
}}
style={smallInputStyle}
/>
);
}
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
{itemFields.map((field) => {
const fieldVal = item[field];
if (typeof fieldVal === 'boolean') {
return (
<label key={field} style={{ fontSize: 10, color: '#71717a', display: 'flex', alignItems: 'center', gap: 4, cursor: 'pointer' }}>
<input type="checkbox" checked={fieldVal} onChange={(e) => {
actions.setProp(selectedId, (props: any) => {
const updated = [...(props[key] || [])];
updated[index] = { ...updated[index], [field]: e.target.checked };
props[key] = updated;
});
}} />
{field}
</label>
);
}
if (typeof fieldVal === 'number') {
return (
<div key={field}>
<label style={{ fontSize: 9, color: '#52525b', textTransform: 'capitalize' }}>{field}</label>
<input type="number" value={fieldVal} onChange={(e) => {
actions.setProp(selectedId, (props: any) => {
const updated = [...(props[key] || [])];
updated[index] = { ...updated[index], [field]: parseFloat(e.target.value) || 0 };
props[key] = updated;
});
}} style={smallInputStyle} />
</div>
);
}
if (/color/i.test(field) && typeof fieldVal === 'string') {
return (
<div key={field} style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
<label style={{ fontSize: 9, color: '#52525b', textTransform: 'capitalize', width: 50 }}>{field}</label>
<input type="color" value={fieldVal || '#000000'} onChange={(e) => {
actions.setProp(selectedId, (props: any) => {
const updated = [...(props[key] || [])];
updated[index] = { ...updated[index], [field]: e.target.value };
props[key] = updated;
});
}} style={{ width: 24, height: 20, border: 'none', cursor: 'pointer', background: 'none', padding: 0 }} />
</div>
);
}
const strVal = String(fieldVal ?? '');
const isLongField = strVal.length > 50 || field === 'description' || field === 'text' || field === 'content';
return (
<div key={field}>
<label style={{ fontSize: 9, color: '#52525b', textTransform: 'capitalize' }}>{field}</label>
{isLongField ? (
<textarea value={strVal} onChange={(e) => {
actions.setProp(selectedId, (props: any) => {
const updated = [...(props[key] || [])];
updated[index] = { ...updated[index], [field]: e.target.value };
props[key] = updated;
});
}} rows={2} style={{ ...smallInputStyle, resize: 'vertical' }} />
) : (
<input type="text" value={strVal} onChange={(e) => {
actions.setProp(selectedId, (props: any) => {
const updated = [...(props[key] || [])];
updated[index] = { ...updated[index], [field]: e.target.value };
props[key] = updated;
});
}} style={smallInputStyle} />
)}
</div>
);
})}
</div>
);
}}
emptyItem={typeof sampleItem === 'object' && sampleItem !== null
? Object.fromEntries(itemFields.map((f) => [f, typeof sampleItem[f] === 'number' ? 0 : typeof sampleItem[f] === 'boolean' ? false : '']))
: ''
}
/>
</CollapsibleSection>
);
})}
{/* Style controls */} {/* Style controls */}
<CollapsibleSection title="Style"> <CollapsibleSection title="Style">
@@ -1,5 +1,4 @@
import React, { useCallback } from 'react'; import React from 'react';
import { useEditor } from '@craftjs/core';
import { import {
StylePanelProps, StylePanelProps,
CollapsibleSection, CollapsibleSection,
@@ -8,16 +7,13 @@ import {
inputStyle, inputStyle,
btnActiveStyle, btnActiveStyle,
sectionGap, sectionGap,
useNodeProp,
} from './shared'; } from './shared';
import { AssetPicker } from '../../../ui/AssetPicker'; import { AssetPicker } from '../../../ui/AssetPicker';
/* ---------- HERO STYLE PANEL ---------- */ /* ---------- HERO STYLE PANEL ---------- */
export const HeroStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeProps }) => { export const HeroStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeProps }) => {
const { actions } = useEditor(); const { setProp } = useNodeProp(selectedId);
const setProp = useCallback((key: string, value: any) => {
actions.setProp(selectedId, (props: any) => { props[key] = value; });
}, [actions, selectedId]);
const bgType = nodeProps.bgType || 'color'; const bgType = nodeProps.bgType || 'color';
@@ -1,4 +1,4 @@
import React, { useCallback, CSSProperties } from 'react'; import React, { CSSProperties } from 'react';
import { useEditor } from '@craftjs/core'; import { useEditor } from '@craftjs/core';
import { import {
IMAGE_RADIUS_PRESETS, IMAGE_RADIUS_PRESETS,
@@ -8,22 +8,17 @@ import {
SectionLabel, SectionLabel,
PresetButtonGrid, PresetButtonGrid,
TextInputField, TextInputField,
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 }) => {
const { actions } = useEditor(); const { actions } = useEditor();
const style: CSSProperties = nodeProps.style || {}; const style: CSSProperties = nodeProps.style || {};
const setPropStyle = useCallback( const { setPropStyle } = useNodeProp(selectedId);
(property: string, value: string) => {
actions.setProp(selectedId, (props: any) => {
props.style = { ...props.style, [property]: value };
});
},
[actions, selectedId],
);
const maxWidthPresets = [ const maxWidthPresets = [
{ label: '25%', value: '25%' }, { label: '25%', value: '25%' },
@@ -39,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,116 @@
import { describe, test, expect, vi, beforeEach, afterEach } from 'vitest';
import React from 'react';
import { createRoot, Root } from 'react-dom/client';
import { act } from 'react-dom/test-utils';
/* MediaStylePanel (via useNodeProp/ArrayPropEditor in ./shared) only needs
useEditor from @craftjs/core. Mock it following the DOM-harness pattern
used in Footer.editguard.test.tsx / AssetPicker.test.tsx (no
@testing-library/react in this repo) so setProp calls can be observed
without mounting a real <Editor> tree. */
const setPropSpy = vi.fn((_id: string, updater: (p: any) => void) => {
updater(lastProps);
});
let lastProps: any;
vi.mock('@craftjs/core', () => ({
useEditor: () => ({ actions: { setProp: setPropSpy } }),
}));
vi.mock('../../../utils/assets', () => ({
uploadAsset: vi.fn(),
listAssets: vi.fn(),
}));
import { MediaStylePanel } from './MediaStylePanel';
let container: HTMLDivElement;
let root: Root;
function render(ui: React.ReactElement) {
container = document.createElement('div');
document.body.appendChild(container);
act(() => {
root = createRoot(container);
root.render(ui);
});
}
function unmount() {
act(() => { root.unmount(); });
container.remove();
}
function q<T extends Element = Element>(testId: string): T | null {
return container.querySelector(`[data-testid="${testId}"]`);
}
function setValue(input: HTMLInputElement, value: string) {
const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value')!.set!;
setter.call(input, value);
input.dispatchEvent(new Event('input', { bubbles: true }));
}
beforeEach(() => {
setPropSpy.mockClear();
});
afterEach(() => {
if (container) unmount();
});
/**
* INT: ContentSlider (src/components/sections/ContentSlider.tsx) reads
* `slide.imageSrc` in both render and toHtml, but the Slides array editor
* here guarded on `item.image !== undefined` and wrote `image` via
* AssetPicker's onChange -- a key that ContentSlider never reads. Default
* slides (`imageSrc:''`, no `image` key) therefore showed NO image picker at
* all (the guard was never true), and even a manually-added `image` value
* was a silent no-op on export/render.
*/
describe('MediaStylePanel Slides editor uses imageSrc (INT)', () => {
test('a slide shaped like defaultSlides (imageSrc present, no `image` key) renders the image AssetPicker', () => {
lastProps = { slides: [{ type: 'image', imageSrc: '', heading: 'First Slide', text: '', bgColor: '' }] };
render(<MediaStylePanel selectedId="cs-1" nodeProps={lastProps} />);
// Before the fix, the guard `item.image !== undefined` was false for
// this default-shaped slide, so no AssetPicker rendered at all.
expect(q('asset-picker-compact')).not.toBeNull();
});
test('editing the slide image writes imageSrc (not image) via setProp', () => {
lastProps = { slides: [{ type: 'image', imageSrc: '', heading: 'First Slide', text: '', bgColor: '' }] };
render(<MediaStylePanel selectedId="cs-1" nodeProps={lastProps} />);
const urlInput = q<HTMLInputElement>('asset-picker-url-input')!;
expect(urlInput).not.toBeNull();
setValue(urlInput, 'https://example.com/slide.jpg');
act(() => {
urlInput.dispatchEvent(new FocusEvent('focusout', { bubbles: true }));
});
expect(setPropSpy).toHaveBeenCalled();
const updatedSlide = lastProps.slides[0];
expect(updatedSlide.imageSrc).toBe('https://example.com/slide.jpg');
expect(updatedSlide.image).toBeUndefined();
});
test('adding a new slide (emptyItem) includes imageSrc, matching defaultSlides shape', () => {
lastProps = { slides: [] };
render(<MediaStylePanel selectedId="cs-1" nodeProps={lastProps} />);
// The "+ Add Item" button for the Slides ArrayPropEditor.
const addButtons = Array.from(container.querySelectorAll('button')).filter(
(b) => b.textContent?.includes('Add Item'),
);
expect(addButtons.length).toBeGreaterThan(0);
act(() => {
addButtons[0].dispatchEvent(new MouseEvent('click', { bubbles: true }));
});
expect(setPropSpy).toHaveBeenCalled();
expect(lastProps.slides).toHaveLength(1);
expect(lastProps.slides[0]).toHaveProperty('imageSrc');
expect(lastProps.slides[0].image).toBeUndefined();
});
});
@@ -1,4 +1,4 @@
import React, { useCallback } from 'react'; import React from 'react';
import { useEditor } from '@craftjs/core'; import { useEditor } from '@craftjs/core';
import { import {
BG_COLORS, BG_COLORS,
@@ -17,22 +17,14 @@ import {
inputStyle, inputStyle,
smallInputStyle, smallInputStyle,
sectionGap, sectionGap,
useNodeProp,
} from './shared'; } from './shared';
import { AssetPicker } from '../../../ui/AssetPicker'; import { AssetPicker } from '../../../ui/AssetPicker';
/* ---------- MEDIA (Video / Gallery / Map / Slider) ---------- */ /* ---------- MEDIA (Video / Gallery / Map / Slider) ---------- */
export const MediaStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeProps }) => { export const MediaStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeProps }) => {
const { actions } = useEditor(); const { actions } = useEditor();
const { setProp, setPropStyle } = useNodeProp(selectedId);
const setProp = useCallback((key: string, value: any) => {
actions.setProp(selectedId, (props: any) => { props[key] = value; });
}, [actions, selectedId]);
const setPropStyle = useCallback((key: string, value: string) => {
actions.setProp(selectedId, (props: any) => {
props.style = { ...props.style, [key]: value };
});
}, [actions, selectedId]);
const style = nodeProps.style || {}; const style = nodeProps.style || {};
@@ -153,14 +145,14 @@ export const MediaStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodePro
}); });
}} placeholder="Text" rows={2} style={{ ...smallInputStyle, resize: 'vertical' }} /> }} placeholder="Text" rows={2} style={{ ...smallInputStyle, resize: 'vertical' }} />
)} )}
{item.image !== undefined && ( {item.imageSrc !== undefined && (
<AssetPicker <AssetPicker
variant="compact" variant="compact"
value={item.image || ''} value={item.imageSrc || ''}
onChange={(url) => { onChange={(url) => {
actions.setProp(selectedId, (props: any) => { actions.setProp(selectedId, (props: any) => {
const updated = [...(props.slides || [])]; const updated = [...(props.slides || [])];
updated[index] = { ...updated[index], image: url }; updated[index] = { ...updated[index], imageSrc: url };
props.slides = updated; props.slides = updated;
}); });
}} }}
@@ -168,7 +160,7 @@ export const MediaStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodePro
)} )}
</div> </div>
)} )}
emptyItem={{ heading: 'New Slide', text: '', image: '' }} emptyItem={{ type: 'image', imageSrc: '', heading: 'New Slide', text: '', bgColor: '' }}
/> />
</CollapsibleSection> </CollapsibleSection>
)} )}
@@ -15,22 +15,14 @@ import {
inputStyle, inputStyle,
smallInputStyle, smallInputStyle,
sectionGap, sectionGap,
useNodeProp,
} from './shared'; } from './shared';
import { AssetPicker } from '../../../ui/AssetPicker'; import { AssetPicker } from '../../../ui/AssetPicker';
/* ---------- NAV / MENU / LOGO ---------- */ /* ---------- NAV / MENU / LOGO ---------- */
export const NavStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeProps }) => { export const NavStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeProps }) => {
const { actions } = useEditor(); const { actions } = useEditor();
const { setProp, setPropStyle } = useNodeProp(selectedId);
const setProp = useCallback((key: string, value: any) => {
actions.setProp(selectedId, (props: any) => { props[key] = value; });
}, [actions, selectedId]);
const setPropStyle = useCallback((key: string, value: string) => {
actions.setProp(selectedId, (props: any) => {
props.style = { ...props.style, [key]: value };
});
}, [actions, selectedId]);
const links: any[] = nodeProps.links || []; const links: any[] = nodeProps.links || [];
@@ -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);
}
});
});
@@ -1,5 +1,4 @@
import React, { useCallback } from 'react'; import React from 'react';
import { useEditor } from '@craftjs/core';
import { import {
BG_COLORS, BG_COLORS,
SPACING_PRESETS, SPACING_PRESETS,
@@ -12,27 +11,17 @@ import {
PresetButtonGrid, PresetButtonGrid,
CollapsibleSection, CollapsibleSection,
ColorPickerField, ColorPickerField,
ArrayPropEditor,
labelStyle, labelStyle,
inputStyle, inputStyle,
smallInputStyle,
sectionGap, sectionGap,
useNodeProp,
} from './shared'; } from './shared';
import { FeaturesEditor } from './FeaturesEditor'; import { FeaturesEditor } from './FeaturesEditor';
import { ArrayItemFieldsEditor } from './ArrayItemFields';
/* ---------- SECTION-TYPE (Accordion, Tabs, Pricing, Testimonials, etc.) ---------- */ /* ---------- SECTION-TYPE (Accordion, Tabs, Pricing, Testimonials, etc.) ---------- */
export const SectionTypePanel: React.FC<StylePanelProps & { typeName: string }> = ({ selectedId, nodeProps, typeName }) => { export const SectionTypePanel: React.FC<StylePanelProps & { typeName: string }> = ({ selectedId, nodeProps, typeName }) => {
const { actions } = useEditor(); const { setProp, setPropStyle } = useNodeProp(selectedId);
const setProp = useCallback((key: string, value: any) => {
actions.setProp(selectedId, (props: any) => { props[key] = value; });
}, [actions, selectedId]);
const setPropStyle = useCallback((key: string, value: string) => {
actions.setProp(selectedId, (props: any) => {
props.style = { ...props.style, [key]: value };
});
}, [actions, selectedId]);
const style = nodeProps.style || {}; const style = nodeProps.style || {};
@@ -108,115 +97,8 @@ export const SectionTypePanel: React.FC<StylePanelProps & { typeName: string }>
} }
const arrayItems = items as any[]; const arrayItems = items as any[];
if (arrayItems.length === 0 && typeof arrayItems[0] !== 'object') return null; if (arrayItems.length === 0 && typeof arrayItems[0] !== 'object') return null;
const sampleItem = arrayItems[0] || {};
const itemFields = typeof sampleItem === 'object' && sampleItem !== null ? Object.keys(sampleItem) : [];
return ( return <ArrayItemFieldsEditor key={key} selectedId={selectedId} propKey={key} items={arrayItems} />;
<CollapsibleSection key={key} title={key.replace(/([A-Z])/g, ' $1').trim()}>
<ArrayPropEditor
selectedId={selectedId}
propKey={key}
items={arrayItems}
renderItem={(item: any, index: number) => {
if (typeof item !== 'object' || item === null) {
return (
<input
type="text"
value={String(item)}
onChange={(e) => {
actions.setProp(selectedId, (props: any) => {
const updated = [...(props[key] || [])];
updated[index] = e.target.value;
props[key] = updated;
});
}}
style={smallInputStyle}
/>
);
}
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
{itemFields.map((field) => {
const fieldVal = item[field];
if (typeof fieldVal === 'boolean') {
return (
<label key={field} style={{ fontSize: 10, color: '#71717a', display: 'flex', alignItems: 'center', gap: 4, cursor: 'pointer' }}>
<input type="checkbox" checked={fieldVal} onChange={(e) => {
actions.setProp(selectedId, (props: any) => {
const updated = [...(props[key] || [])];
updated[index] = { ...updated[index], [field]: e.target.checked };
props[key] = updated;
});
}} />
{field}
</label>
);
}
if (typeof fieldVal === 'number') {
return (
<div key={field}>
<label style={{ fontSize: 9, color: '#52525b', textTransform: 'capitalize' }}>{field}</label>
<input type="number" value={fieldVal} onChange={(e) => {
actions.setProp(selectedId, (props: any) => {
const updated = [...(props[key] || [])];
updated[index] = { ...updated[index], [field]: parseFloat(e.target.value) || 0 };
props[key] = updated;
});
}} style={smallInputStyle} />
</div>
);
}
// color fields
if (/color/i.test(field) && typeof fieldVal === 'string') {
return (
<div key={field} style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
<label style={{ fontSize: 9, color: '#52525b', textTransform: 'capitalize', width: 50 }}>{field}</label>
<input type="color" value={fieldVal || '#000000'} onChange={(e) => {
actions.setProp(selectedId, (props: any) => {
const updated = [...(props[key] || [])];
updated[index] = { ...updated[index], [field]: e.target.value };
props[key] = updated;
});
}} style={{ width: 24, height: 20, border: 'none', cursor: 'pointer', background: 'none', padding: 0 }} />
</div>
);
}
// long text
const strVal = String(fieldVal ?? '');
const isLongField = strVal.length > 50 || field === 'description' || field === 'text' || field === 'content';
return (
<div key={field}>
<label style={{ fontSize: 9, color: '#52525b', textTransform: 'capitalize' }}>{field}</label>
{isLongField ? (
<textarea value={strVal} onChange={(e) => {
actions.setProp(selectedId, (props: any) => {
const updated = [...(props[key] || [])];
updated[index] = { ...updated[index], [field]: e.target.value };
props[key] = updated;
});
}} rows={2} style={{ ...smallInputStyle, resize: 'vertical' }} />
) : (
<input type="text" value={strVal} onChange={(e) => {
actions.setProp(selectedId, (props: any) => {
const updated = [...(props[key] || [])];
updated[index] = { ...updated[index], [field]: e.target.value };
props[key] = updated;
});
}} style={smallInputStyle} />
)}
</div>
);
})}
</div>
);
}}
emptyItem={typeof sampleItem === 'object' && sampleItem !== null
? Object.fromEntries(itemFields.map((f) => [f, typeof sampleItem[f] === 'number' ? 0 : typeof sampleItem[f] === 'boolean' ? false : '']))
: ''
}
/>
</CollapsibleSection>
);
})} })}
{/* Style */} {/* Style */}
@@ -1,4 +1,4 @@
import React, { useCallback } from 'react'; import React from 'react';
import { useEditor } from '@craftjs/core'; import { useEditor } from '@craftjs/core';
import { import {
BG_COLORS, BG_COLORS,
@@ -16,21 +16,13 @@ import {
smallInputStyle, smallInputStyle,
btnActiveStyle, btnActiveStyle,
sectionGap, sectionGap,
useNodeProp,
} from './shared'; } from './shared';
/* ---------- SOCIAL / ICON / STAR RATING ---------- */ /* ---------- SOCIAL / ICON / STAR RATING ---------- */
export const SocialStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeProps }) => { export const SocialStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeProps }) => {
const { actions } = useEditor(); const { actions } = useEditor();
const { setProp, setPropStyle } = useNodeProp(selectedId);
const setProp = useCallback((key: string, value: any) => {
actions.setProp(selectedId, (props: any) => { props[key] = value; });
}, [actions, selectedId]);
const setPropStyle = useCallback((key: string, value: string) => {
actions.setProp(selectedId, (props: any) => {
props.style = { ...props.style, [key]: value };
});
}, [actions, selectedId]);
const style = nodeProps.style || {}; const style = nodeProps.style || {};

Some files were not shown because too many files have changed in this diff Show More