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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
Enter/Space on a clickableProps row unconditionally called
e.preventDefault() + onActivate(), even when the keydown bubbled up from a
nested interactive child (e.g. the Delete/Rename icon buttons in
AssetsPanel/PagesPanel). preventDefault() anywhere in the propagation path
cancels the browser's native click synthesis for the focused child button,
so its onClick never fired and the row's onActivate hijacked the action
instead. Guard on e.target !== e.currentTarget so only keydowns targeted at
the row itself are handled.
- AssetsPanel: asset delete now requires an in-app two-step confirm
(tile-button turns into "Delete?" + cancel, auto-resets after 4s or on
click-elsewhere) instead of deleting with no confirmation at all.
- AssetsPanel: copyUrl uses a new copyToClipboard() helper that tries the
async Clipboard API and falls back to a hidden-textarea execCommand copy
in non-secure contexts, surfacing a visible "Copy failed" state instead
of silently doing nothing.
- SitesmithModal: replaced window.confirm(...) for "Clear chat" with the
same in-app two-step confirm pattern -- no native dialogs.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Two components of the det-id bug class were missed in the earlier migration:
NumberCounter's wrapper/counter ids and count-up script, and the shared
form-relay-wiring fid (used by ContactForm/FormContainer). Both used
Math.random() for exported HTML ids, breaking caching/diffing across exports.
Migrate both to scopeId(nodeId, fallbackSeed, prefix), threading nodeId
through NumberCounter.toHtml and relayFormWiring (via ContactForm.toHtml and
FormContainer.toHtml, both now passing nodeId as their 3rd arg).
ContentSlider: autoplay's setInterval now pauses on mouseenter and on
document visibilitychange (tab hidden), resumes on mouseleave/visible,
and is always clearable (timer var + clearInterval). The aria-live
region is "off" while autoplay is silently auto-rotating and only
flips to "polite" on manual next/prev/dot navigation, so screen readers
aren't spammed with an announcement every `interval` ms (F-export
review Minor). Updates the one existing test that hard-coded
aria-live="polite" as always-on to match this intentional behavior
change.
(Countdown's ticking-interval-stops-at-zero nit shipped in the previous
commit alongside its node-id migration, since both touched the same
inline script.)
Adds regression tests locking in that MapEmbed/VideoBlock iframe src
attributes (built by string concatenation with literal `&` query
params) are HTML-entity-encoded via the existing escapeAttr(safeUrl())
pipeline -- verified already correct, no source change needed there.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Migrates Menu, ColumnLayout, Countdown, Gallery, Tabs, InputField, and
TextareaField from Math.random()/content-hash scope ids to the threaded
Craft node id (via scopeId()), keeping every emitted element id,
aria-controls/aria-labelledby/for, and inline <script> function
name/getElementById() call consistently scoped per component.
Resolves the two Important id-collision review findings:
- Tabs: tabId was djb2(anchorId||labels) -- two default Tabs instances
produced identical aria-controls/aria-labelledby ids, so one instance's
arrow-key script clobbered the other's tab/panel wiring.
- InputField/TextareaField: fieldId was `field-${name}` -- two fields
sharing a (often default) name produced duplicate <label for>/<input
id> pairs, breaking the for/id association for one of them.
Also eliminates the remaining Math.random() scope ids in Menu (hover CSS
class scope) and ColumnLayout (nth-child width CSS class scope), so no
export component is non-deterministic anymore.
All fall back to a stable content hash (never Math.random) for legacy
2-arg toHtml() call sites without a node id.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
renderNode now passes the Craft node id as toHtml's 3rd argument
(props, childrenHtml, nodeId). Backward compatible: the resolver map is
untyped (any), so existing 2-arg toHtml implementations/tests are
unaffected. Adds scopeId()/stableHash() helpers in utils/escape.ts:
scopeId derives a scope string from the node id (deterministic AND
unique, since Craft node ids are unique per node and stable across
repeated exports of the same page) with a stableHash(seed) fallback for
legacy call sites without a node id.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- InputField/TextareaField/ContactForm: every control gets a
deterministic id (slugId() in utils/escape.ts, derived from the
field's name/label + index for ContactForm's looped fields -- no
Math.random) with a matching <label for=>; fields with no visible
label get an aria-label from the placeholder/name instead.
- StarRating: wrapped in role="img" aria-label="Rating: N out of M",
individual star glyphs marked aria-hidden.
- Navbar: the mobile hamburger toggle gets aria-label="Toggle
navigation menu", aria-controls="navbar-links", and aria-expanded
wired to flip true/false in the inline onclick handler.
- VideoBlock and MapEmbed: every exported <iframe> gets a title
(generic "Embedded video", or "Map of {address}" for MapEmbed).
- Decorative Font Awesome icons (ContentSlider arrows already covered
in the prior commit; SocialLinks, SearchBar, Testimonials stars) are
aria-hidden; SocialLinks' icon-only links get an aria-label naming
the platform alongside the existing title tooltip.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
ContentSlider, Tabs, and Gallery toHtml exports and their inline scripts
now carry ARIA semantics and keyboard support:
- ContentSlider: prev/next arrows and dot buttons get aria-labels, the
slide stack is wrapped in an aria-live="polite" region, and the
decorative chevron icons are aria-hidden.
- Tabs: tablist/tab/tabpanel roles, aria-selected/aria-controls/
aria-labelledby, and Left/Right/Home/End arrow-key navigation in the
inline script. The tab<->panel id scope is now derived from a
deterministic hash of anchorId/labels instead of Math.random, since
the ARIA linking ids must be stable across export runs.
- Gallery: the lightbox overlay gets role="dialog"/aria-modal/aria-label,
Escape closes it, and thumbnails are keyboard-operable (role="button"
tabindex="0" plus Enter/Space handling in the existing delegated
listener).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
style: null fell through the shallow-merge branch's `value && typeof
value === 'object'` guard and hit the `p[key] = value` fallback, setting
p.style = null and losing every existing style key. Treat style: null (or
any other non-object style value) as "ignore this key" instead.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
buildNodeTree now delegates its structural walk (ColumnLayout linkedNodes,
SHELL_INNER wrapping, style:[]->{} normalization) to flattenTreeForCraft,
materializing each flat node into a real Craft.js Node via
query.parseFreshNode — except the synthetic SHELL_INNER wrapper, which is
still hand-built (parseFreshNode would merge in Container's default
craft.props and change its look). sanitizeAiTree now lives in craft-tree.ts
and is re-exported here for existing callers.
Delete the dead serializeTreeForCraft (only its own tests called it) and
its now-orphaned tests.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
treeToState had no resolvedName allowlist, so an AI `replace` response
(scope site/page/header/footer, reaches this function via
actions.deserialize with no further validation downstream) containing an
unknown component could produce a state that throws at deserialize/render.
Extract the core transform into standalone treeToCraftState (directly
unit-testable without mounting a Craft.js Editor), route it through
sanitizeAiTree for the same resolvedName allowlist + id/ROOT repair
buildNodeTree already had, and fall back to the empty canvas if the tree's
own root is invalid. Delegate the structural walk to the shared
flattenTreeForCraft extracted in the previous commit.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
PageContext.treeToState, apply-ai-response.buildNodeTree, and the dead
serializeTreeForCraft each re-implemented the same ColumnLayout-linkedNodes +
SHELL_INNER walk and had drifted (buildNodeTree gained guards the others
lacked). Move sanitizeAiTree (resolvedName allowlist + id/ROOT repair) here
too and add flattenTreeForCraft, the shared structural walk (linkedNodes,
SHELL_INNER wrapping, style:[]->{} normalization) both real callers will
consolidate onto.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Footer only committed edited text via onBlur, and its effect rewrote
innerText from the (stale) text prop whenever selected became false --
if selection cleared without a real DOM blur, the in-progress edit was
silently lost. Adopt Heading.tsx's exact mechanism: an editedTextRef
updated on onInput, committed to the prop via an effect keyed on the
selected->false transition (in addition to the existing onBlur commit).
Preserves the 500ms setProp debounce Footer already had for undo grouping.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Extend detectVideoType to handle youtube.com/shorts/ID, youtube.com/live/ID,
youtube.com/watch?...&v=ID (v not the first query param), and Vimeo
private-hash URLs (vimeo.com/ID/HASH -> ?h=HASH player param). Existing
shapes (youtu.be/ID, youtube.com/embed/ID, youtube.com/watch?v=ID,
vimeo.com/ID, direct files) keep working. The emitted embed src still
passes through safeUrl unchanged.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
toHtml emitted only the wrapper's flex/gap style and passed childrenHtml
through untouched, so a non-default split percentage never showed up in
the export -- each column's width silently reverted to whatever was baked
into its own inline style at node-creation time. toHtml has no direct
handle on individual children to rewrite their styles (it only receives
the already-concatenated childrenHtml string), so emit a scoped <style>
block with nth-child rules (!important, to win over any stale per-child
inline flex) computed via the same getWidths(split, columns) mapping the
editor render uses. Precedent: Menu/Navbar toHtml already emit scoped
<style> blocks for hover CSS.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The editor rendered layout="single" as an interactive carousel (prev/next
buttons, dot indicators, currentIndex state) but toHtml exported a static
stacked list of ALL testimonials -- a structural mismatch. This codebase's
static export has no published-JS interactivity for this component, so
static-parity is the lower-risk fix: the editor's single view now renders
just the first testimonial (no carousel chrome), and toHtml exports
exactly that one card. Grid layout is unchanged.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
FeatureItem.mediaType was never read anywhere -- the icon-vs-image choice
is (correctly) driven by feat.image truthiness, set via the AssetPicker in
FeaturesEditor. Neither the default items nor FeaturesEditor's "add item"
object ever set it. Remove the dead field; image-precedence render/export
behavior is unchanged.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
successMessage was collected in props (and FormStylePanel already had a
live "Success Message" input for it) but toHtml never emitted it anywhere.
The form-sender relay delivers success via a full-page 303 redirect to
thankYouUrl or a hosted thanks.php page -- there is no in-page JS today
that could reveal an inline success element. Emit it as
data-whp-success-message (escaped) on the <form>: forward-compatible for a
future AJAX submission mode, without implying a live mechanism that
doesn't exist yet.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Container.craft.props collected cssId/cssClass since before Phase E1 but
nothing rendered or exported them. Add live "CSS ID" / "CSS Class" text
inputs to ContainerStylePanel (guarded on nodeProps.cssId/cssClass
!== undefined) and emit id=/class= in both the editor render and toHtml.
cssId takes precedence over the existing anchorId prop when both are set
(only one id attribute can be emitted); anchorId is used as a fallback.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
PageData.headCode was never populated from anything but '' and never
read anywhere -- the real, live concept is SiteDesign.headCode (set
via the Code modal, wired into Preview/export in D8). Remove the dead
field from the PageData interface and its ~7 hardcoded headCode: ''
seeds/discards in PageContext.tsx (DEFAULT_PAGE, DEFAULT_HEADER,
DEFAULT_FOOTER, the context default value, addPage, setPagesCraftState,
replaceAllPages). Update the PageData literals in
useWhpApi.save.test.ts to match the trimmed interface.
Verified via grep that nothing reads page.headCode, and `npm run
build` (strict tsc) is clean -- no live reader broke.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Two bugs in TopBar's Preview handler:
1. exportToHtml() was called without a headCode option, so Preview
silently ignored SiteDesign.headCode (custom <head> code the user
set via the Code modal). Pull `design.headCode` from useSiteDesign()
and pass it through, matching exportToHtml's ExportOptions.headCode
signature.
2. html.replace(bodyMatch[1], composedBody) used the string-replacer
overload of String.prototype.replace, which treats `$&`, `$1`,
`$$`, etc. in the replacement string as special patterns -- any
user content containing a literal `$` sequence would corrupt the
preview HTML. Switch to a function replacer
(`.replace(bodyMatch[1], () => composedBody)`) so the composed body
is inserted literally.
Verified by reading + `npm run build`; no existing TopBar test harness
to extend (dynamic import + window.open/Blob in the click handler).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
switchPage and deletePage ran side effects (loadState -> actions.deserialize,
setActivePageId, activePageIdRef mutation) INSIDE setPages/setHeaderPage/
setFooterPage updater callbacks -- using the functional-updater form purely
to peek at the latest `prev` value. React (in StrictMode dev builds)
double-invokes updater functions passed to setState to catch exactly this
kind of impurity, so every page switch deserialized the target's craft
state twice.
Add pagesRef/headerPageRef/footerPageRef mirroring the latest state on
every render (same pattern already used for activePageIdRef), so
switchPage/deletePage can read "what's current" synchronously in the
event-handler body and run their side effects there -- after the state
update is computed, not inside the updater. deletePage now passes a plain
next-value to setPages instead of a function updater. Switching/deleting
behavior (correct page loads, can't delete the last page) is unchanged.
Verified the added test fails against the pre-fix code (deserialize called
2x) and passes against the fix (1x).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The template-load sequence (clear canvas -> add components -> add
pages -> apply header/footer) ran as a chain of setTimeout callbacks,
but onClose() fired synchronously right after kicking it off. The
modal disappeared immediately, so "Loading..." never had a chance to
show, and the staged mutations kept running against a dialog the
caller had already dismissed.
Convert the staged steps to async/await (a promise-based `wait()`
replaces the setTimeout chaining) and only call onClose() in a
finally block once the whole sequence has settled. Add a mountedRef
guard so the trailing setLoading(false) is skipped if the component
happens to unmount mid-sequence. End result (pages/header/footer/
design applied) is unchanged.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Two pages that slugify to the same string (e.g. both named "About")
previously both published to about.html, silently overwriting each
other on publish. Add a pure uniqueSlug(base, existingSlugs) helper
that appends -2, -3, ... on collision, and apply it in addPage,
renamePage, and replaceAllPages. The landing page's slug stays locked
to 'index' regardless of collisions, matching existing behavior.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
HeaderZone and FooterZone are absent from componentResolver (so they can
never be deserialized/rendered) and are not imported by PageContext,
Canvas, or any other live code -- verified via repo-wide grep returning
only their own self-references. The site-wide header/footer feature uses
Container with tag="header"/"footer", not these zone wrappers. Deleted.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
After removing the dead component settings UI, these ui/ controls have
zero importers: AdvancedTab, TypographyControl, BorderControl,
SpacingInput, AnchorIdField, SettingsTabs. Verified no live *StylePanel
references them (the style panels import only AssetPicker, which is
kept). Deleted the six orphans.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Each component defined a .craft.related.settings panel that was never
rendered -- the right panel renders only GuidedStyles (per-type
*StylePanel components), never .related.settings. Removed all dead
settings components across every component, their settings-only helpers
(including the dead uploadToWhp/showBrowser/handleBrowse asset-browse
blocks in Logo/Navbar/VideoBlock/FeaturesGrid, and CtasEditor in
_cta-helpers), and dropped the now-empty related keys. Render output,
.craft props/rules, and toHtml statics are unchanged.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Replace the ad-hoc upload/browse/URL blocks in ImageStylePanel, HeroStylePanel
(bgImage + bgVideo), BackgroundSectionStylePanel, NavStylePanel (standalone
Logo imageSrc + Navbar logoImage), MediaStylePanel (Gallery/ContentSlider
array items), and FeaturesEditor (per-feature image) with the shared
AssetPicker component, dropping each panel's duplicated local
showBrowser/browserAssets/handleUpload state and inline asset-browser JSX.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Introduces src/ui/AssetPicker.tsx, a single component that replaces the
~6 copy-pasted image-source UIs (upload/browse/URL) with one that also
supports video via `mediaType`. Full variant mirrors ImageStylePanel's
existing look; compact variant is a tight single row for array-editor
item cards. Both share all state/handler logic, built on the C1
uploadAsset/listAssets util.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Extract uploadAsset/listAssets into utils/assets.ts, lifting the exact
uploadToWhp body and list_assets fetch pattern already duplicated across
shared.tsx/ImageBlock/Logo/Navbar/etc. shared.tsx's uploadToWhp is now a
thin re-export (`export const uploadToWhp = uploadAsset`) so its ~6
existing callers are unaffected.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds an AI-boundary validation/sanitization pass in apply-ai-response.ts so
malformed or malicious Sitesmith output can never crash addNodeTree or
corrupt live craft state:
- resolvedName allowlist: any node whose type.resolvedName is not a
registered component in componentResolver is dropped (with its subtree)
and a console.warn is logged; buildNodeTree throws before touching
Craft.js if the tree ROOT itself is invalid, which existing call sites
already catch and warn on (fail soft, never throws into addNodeTree).
- update_props protected-key list (node_id) — an AI-supplied node_id can no
longer overwrite the target node's real id; style is merged shallowly
instead of replaced wholesale so unrelated existing style keys survive.
- id policy: regenerate, never skip. Any AI-supplied node id that is
'ROOT', empty/non-string, or collides with an existing/already-used id is
replaced with a fresh ai-auto-N id; the node itself is kept.
Extends apply-ai-response.test.ts with coverage for all of the above plus
regression tests proving fully-valid AI responses still apply exactly as
before.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Editing the Header/Footer sets activePageId to '__header__'/'__footer__',
which matches no entry in `pages`. save() was serializing the live canvas
into the top-level page slots (mislabeled as page content, matching no
page) while exporting header/footer from stale stored state — auto-save
every 30s silently dropped header/footer edits.
Extract buildSavePayload() as a pure, unit-tested helper: header/footer
craft state now comes from the live canvas when that zone is being
edited (else stored state), and the top-level page fields fall back to
the landing page's stored state when a header/footer zone is active,
so page content is never clobbered or mislabeled.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
regenerateTreeIds shallow-copied each node's data, leaving data.props (and
data.custom) as the same object reference between the original node and its
duplicate/pasted copy. Craft.js's setProp mutates data.props in place, so
editing the duplicate's props silently mutated the original too. Deep-clone
data via structuredClone before applying id remaps so no mutable sub-object
is shared between original and regenerated nodes.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Craft.js duplicate (ContextMenu + keyboard shortcut) and paste were reusing
the original node's toNodeTree() output verbatim, so addNodeTree() inserted
duplicate node ids into the editor tree. Added regenerateTreeIds() which
deep-clones a NodeTree and remaps rootNodeId, node map keys, node.id,
internal node.data.parent, node.data.nodes, and node.data.linkedNodes via
Craft.js's own getRandomId(). Also fixed pasteNode to insert as a sibling
of the right-clicked node (using its parent) instead of using a leaf node
as the new parent, which previously threw.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
sanitizeCssValue's blanket `;` strip ran on the whole value AFTER url(...)
content was already safely re-wrapped, corrupting legitimate
data:image/png;base64,... URLs pasted into Background Image fields
(the MIME/base64 separator `;` was deleted, breaking the data URI in
exported/published HTML). Scope the `;`/`"` breakout sanitization to the
segments outside url(...) matches only -- the url() branch is already
fully safe via escapeAttr(safeUrl(...)) and must not be re-stripped.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
cssPropsToString() joined raw CSSProperties values into a style="..."
attribute with zero escaping, so any component spreading user-controlled
values into inline styles (background-image url(), etc.) could break
out of the attribute or inject a second declaration -- this is what
made BackgroundSection/HeroSimple/CallToAction/Section's bg-image
url() sites (flagged in the A3 brief) safe without needing a per-call-
site fix, since they already route through this helper.
Each string value is now sanitized: url(...) contents are validated
through safeUrl and re-wrapped escaped, stray `;` (the only way to
inject a second live declaration) is stripped, and any raw `"` is
entity-encoded so it can't terminate the attribute early. Legitimate
multi-part values (box-shadow, gradients) that contain none of these
characters pass through byte-identical.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Entity-escaping alone doesn't protect JS-string or raw-HTML sinks:
- HtmlBlock.toHtml exported props.code raw; now runs it through the
same purifyHtml (DOMPurify) config already used for the live editor
preview, so <script>/on*= payloads can't survive export either.
- Countdown.toHtml interpolated targetDate directly into
`new Date("${targetDate}")` inside an inline <script> -- a value
like `2026-01-01");alert(1)//` broke out of the string literal. Now
validated against a strict date/datetime shape and JSON.stringify'd
before embedding, falling back to `new Date()` for anything invalid.
- Gallery.toHtml's lightbox used
`onclick="${id}_open('${esc(img.src)}')"`, which a single quote in
img.src could break out of. Replaced with a `data-lb-src` attribute
per thumbnail and one delegated click listener on the grid
(`e.target.closest('[data-lb-src]')`) instead of a per-item inline
handler string.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Every user-controlled URL emitted by a component's static toHtml (href,
src, action, and CSS url()) now runs through escapeAttr(safeUrl(...))
before hitting the exported HTML string, closing the XSS gaps flagged
in the A2 review (PricingTable buttonHref was fully unescaped, Gallery/
HeroSimple/ImageBlock/VideoBlock/etc. lacked scheme filtering) plus a
few more found via a grep sweep of href=/src=/action=/url( inside
toHtml template strings: MapEmbed's iframe src and the shared
form-relay-wiring fallback form action (a javascript: form action
executes on submit).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Replace divergent, buggy local esc/escapeHtml helpers across 26 files with
imports from src/utils/escape (escapeHtml/escapeAttr). Attribute call sites use
escapeAttr, text-content sites use escapeHtml. Several toHtml outputs now
correctly escape & where old local escapers omitted it.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>