Compare commits

..
Author SHA1 Message Date
shadowdaoandClaude Opus 5 422697acec fix(site-builder): review findings on the webhook destination controls
I-1: WebhookSecretField now mounts with key={selectedId}. GuidedStyles renders
FormStylePanel with no key, so a selection change re-rendered rather than
remounted it and React kept `draft`/`status`. Since a failed store deliberately
retains the draft, clicking a second contact form showed node A's raw secret in
node B's field, and the next blur assigned the returned secret_id to the wrong
form (burning a slot against the per-site cap). The status banner leaked the
same way. Both pinned by the reviewer's repro sequence.

I-2: the button is "Clear", not "Remove", and says so -- nothing deletes a
stored key file, so the 50-per-site cap counts stores-ever. Labelling it Remove
told the customer they had reclaimed a slot right up until the 429 that said
otherwise. Actually deleting the file is Task 8's territory.

M-1: inline warning when the webhook URL is blank or not absolute https. The
publish step does refuse these, but into an error_log the customer never reads.
Warning only -- isHttpsWebhookUrl never edits the value or blocks the publish,
since either would trade a loud server-side refusal for a silently inert form.

M-2: the BYTE-IDENTITY test now compares against a literal marker captured by
executing the emitter at 071f3447, not against another head-revision output. The
self-comparison could only catch a drift affecting one side; a uniform one
passed it. Verified: a uniform `<!-- WHP-FORM` drift now fails this assertion.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-10 15:57:38 -07:00
shadowdaoandClaude Opus 5 d1c57db967 feat(site-builder): webhook destination controls on the contact form
ContactForm gains four craft props (destinationType, webhookUrl,
webhookSecretId, webhookAuthMode), all present in craft.props defaults so
FormStylePanel's `nodeProps.X !== undefined` gates actually render their
controls. The controls live in FormStylePanel (RightPanel renders only
GuidedStyles, so related.settings would be dead UI).

relayFormWiring widens the marker to optionally carry type/url/secret/
authmode BETWEEN `id` and `recipient`, which is where FormRelayRewrite.php's
parser looks. A marker with no type is byte-identical to what shipped before
-- pinned by a test that diffs an explicit-email form against one with no
destination props at all, since every already-published site depends on that
shape continuing to provision an email endpoint.

Every optional attribute value goes through one escaping site (markerAttr ->
escapeAttr); type and authmode are additionally allowlisted, so a case-drifted
"Bearer" reaches the relay as the exact literal it compares against instead of
being silently downgraded to unsigned.

The raw shared secret is never a prop: it is held in WebhookSecretField's
local state, POSTed to /api/form-webhook-secret.php on blur, and only the
returned opaque id is persisted. The field is write-only (set / replace /
remove, never view) because the endpoint has no read route, and the endpoint's
429 cap message is surfaced verbatim so a customer can act on it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-10 15:45:05 -07:00
shadowdaoandClaude Opus 5 071f3447fd Add static security advisory to HTML block style panel
Custom HTML block renders arbitrary markup on the published site.
Scripts and event handlers are stripped, but forms, iframes, and
images survive and can send data elsewhere. Add a plain, always-shown
advisory (no content detection) alongside the existing wrapper-styling
note.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-10 08:37:42 -07:00
shadowdaoandClaude Opus 5 98f2ebf118 Fix: HtmlBlock sanitizer strips select/meter presentation attrs
size, low, high, and optimum were missing from PURIFY_CONFIG.ALLOWED_ATTR
even though <select> and <meter> are already in ALLOWED_TAGS, so
<select size="4"> rendered at default height and <meter low/high/optimum>
lost its threshold-based gauge colouring. All four are pure
presentation/semantic attributes with no URL/script/event-handler
surface, so no security implication.

Also regenerates the pinned pre-Task-25 output fixture: its source
(html-block-test-body.html) already exercises size/low/high/optimum, so
the byte-identity test's expected output legitimately changes to include
them; verified the regenerated fixture's only diff from the prior one is
those four attributes now surviving.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-10 07:23:36 -07:00
shadowdaoandClaude Opus 5 916a568e9f fix(site-builder): address Task 25 review findings on <style> scoping
Four issues from adversarial review of the block-scoped <style> feature:

1. (Critical) transformBlock() recursed once per @media/@supports/@container
   nesting level with no cap -- ~7000 nested rules blew the call stack, and
   nothing between a Custom HTML block's toHtml() and the publish pipeline
   catches exceptions, so this took down the whole page's publish and
   crashed the live editor on every keystroke. Added MAX_NESTING_DEPTH=20
   (pass the body through unscoped beyond it) and wrapped scopeCss() so it
   never throws on any input, matching repairOrphanNodes's existing
   contract. Caught and fixed a variable-shadowing bug in my own first pass
   at this: the new depth parameter was silently shadowed by a pre-existing
   `let depth` used for brace-matching in the same block, which would have
   defeated the cap with no type error.

2. (Important) FORCE_BODY: true was unconditional, but it isn't a no-op for
   style-free input: it also changes how the parser preserves whitespace
   after a LEADING html comment, which this repo's own fixture starts with.
   Verified via a raw byte-diff against HtmlBlock.tsx@6a9b227 (extracted
   verbatim, run standalone against real dompurify+jsdom) that the fixture
   gained bytes. Fixed by applying FORCE_BODY only when the input has a
   real (non-comment) <style> tag to rescue -- confirmed empirically that
   this is a true no-op for every other input. Pinned the old output as a
   checked-in regression fixture and added a raw toBe() diff test.

3. (Important) scopeStyleBlocks() wasn't idempotent -- pasting previously
   published/exported output into a fresh block nested a second wrapper
   and re-prefixed every selector. Added isAlreadyScoped(), which detects
   a lone root wrapper whose <style> content is already a no-op under
   scopeCss for that wrapper's own class (reusing scopeCss's own
   idempotency guarantee) and leaves it untouched.

4. (Minor) Documented, not fixed: the 32-bit scope-id hash is
   brute-forceable (CSS-only impact, same trust tier as other accepted
   risks here), and DOMPurify's SAFE_FOR_XML silently drops an entire
   <style> block when its content merely looks tag-like (e.g.
   content: "<Read More>").

1155/1155 tests passing (was 1141), tsc clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 17:36:12 -07:00
shadowdaoandClaude Opus 5 32f4092156 feat(site-builder): add block-scoped <style> support to Custom HTML block
<style> was previously in FORBID_TAGS and stripped entirely. It's now
allowed, but its CSS is rewritten by a new hand-rolled scoper
(src/utils/scope-css.ts) so a customer's rules only match inside their own
block's wrapper -- never leak out and restyle the rest of the page. The
wrapper div (class="whp-html-<hash>") is only emitted when a block actually
has surviving <style> content, so blocks that don't use it stay
byte-identical to before this change.

Key findings, both covered by tests:
- DOMPurify's body-only serialization silently drops a <style> tag that
  appears before any other content in a block (the HTML5 parser implicitly
  places it in <head>, which DOMPurify never looks at). Fixed with
  FORCE_BODY: true.
- DOMPurify does not sanitize CSS declaration values at all (expression(),
  behavior:, url() to any host all pass through verbatim) -- @import is
  stripped explicitly by scopeCss() since it's the one CSS-level
  exfiltration/fetch vector in scope here.

Scope identifier reuses the existing djb2 stableHash() from utils/escape.ts
(already used for this exact class of problem) over the block's own `code`
string -- deterministic, no node id, no Math.random/Date.now.

1141/1141 tests passing (was 1077), tsc clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 17:10:57 -07:00
shadowdaoandClaude Opus 5 6a9b227dda fix(site-builder): repair dead-code data:image clause in HTML block URI regex
Review follow-up on the Task 24 sanitiser widening (approved, no bypass
found). Two Important findings to close:

1. ALLOWED_URI_REGEXP's data:image/...;base64, arm sat inside the group
   that appends a trailing `:` to every alternative, so it required a
   second colon no real data URI has -- the clause could never match.
   Confirmed dead before the fix (poster/cite/href all stripped a valid
   base64 PNG data URI) and working after (all three now survive), while
   javascript:/data:text/html stay blocked. Pulled the arm out into its
   own top-level alternative.

2. Corrected an inaccurate comment/report claim that every allowed
   attribute value goes through this regex -- `src` on
   img/video/audio/source/image/track is additionally covered by
   DOMPurify's own DATA_URI_TAGS allow-list, which is mimetype-blind and
   bypasses the regex entirely (acceptable: none of those tags execute
   src as a document; iframe is correctly excluded from that list).

Adds two regression tests: the regex fix actually working, and the
DATA_URI_TAGS bypass pinned so a future DOMPurify change surfaces as a
failing test rather than a surprise. Fixture byte figures unchanged
(15,815 -> 14,899; fixture has no data:image URIs).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 16:50:32 -07:00
shadowdaoandClaude Opus 5 156c5bae35 fix(site-builder): widen Custom HTML block sanitiser allow-list
A customer's broad HTML fixture showed 38% of it silently deleted by
the shipped DOMPurify config: colspan/rowspan/scope, <dl>, <sub>/<sup>,
<details>/<summary>, inline <svg>, <video>/<audio>, lang/dir/role, and
<ol start/reversed> were all stripped. The site owner's call: be
generous, this block is an explicit escape hatch, allow forms too.

Widens PURIFY_CONFIG in HtmlBlock.tsx (45->119 tags, 16->108 attrs;
form/input/button/select/textarea removed from FORBID_TAGS) while
keeping the four non-negotiables intact: no <script>, no on*, no
javascript: URLs, iframes stay sandboxed. <style> stays blocked
(separate task adds scoped support later), including inside the newly
allowed inline SVG. SVG support is an explicit tag list mirroring
DOMPurify's own SVG vocabulary rather than USE_PROFILES, which turned
out to silently discard ALLOWED_ATTR entirely and pull in unaudited
tags (dialog, template, marquee, ...) not in scope here.

Fixture survival goes from 61.6% (9,739/15,815 bytes) to 94.2%
(14,899/15,815 bytes). Adds a fixture-driven regression + security
test file (HtmlBlock.security.test.ts) plus a checked-in copy of the
reference fixture, loaded via Vite's ?raw import so tests need no new
dependencies and can't silently drift from the thing being tested.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 16:36:01 -07:00
shadowdaoandClaude Opus 5 69e61ab4b2 fix(site-builder): final whole-branch review fixes
C1: HtmlBlock's PURIFY_CONFIG omitted 'style' from ALLOWED_ATTR, so the
toolbar colour picker added in this branch was silently deleted by
DOMPurify -- issue #2 was regressed, not fixed. Adds style/id plus table
tags, with tests pinning the markup path in both render and toHtml.

I3: PagesPanel's three confirmation states were not mutually exclusive;
cancelling delete revealed an unbidden reset prompt on a destructive action.

I5: orphan repair logged at console.warn, which the new console buffer
cannot see -- the reporter would never capture the most diagnostic signal
for the still-unreproduced drop bug. Also aligns useWhpApi's initial-load
failure handling with loadState's fallback.

I7: corrects comments (and the design spec) that asserted an orphan
"renders somewhere on the canvas", which a mid-plan audit disproved.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 12:47:23 -07:00
shadowdaoandClaude Opus 5 3dd6b54a35 fix(site-builder): address Task 19 review — onChange convention, opt-out copy
Review findings on the Report an Issue modal:

1. Revert the description textarea from onInput back to onChange. The
   onInput swap sidestepped a real React value-tracker dedup gotcha (a
   test harness's raw `el.value = x` assignment looks like a no-op change
   to a controlled onChange input), but this codebase already has the
   correct fix for exactly that gotcha: bypass the tracker's patched
   setter via the native prototype descriptor, as HeadCodeModal.test.tsx,
   SiteDesignPanel.reset.test.tsx, shared-controls.test.tsx, and both
   MediaStylePanel.*.test.tsx already do. Rewrote typeDescription() in
   ReportIssueModal.test.tsx to use that idiom instead of bending the
   component to fit the test.

2. The include-contents opt-out copy said unchecking it still sends "your
   description, the page name and your browser details" but omitted
   console errors, which buildReportPayload always includes regardless of
   the checkbox and which can incidentally echo page content. Copy now
   lists console errors explicitly.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 11:39:22 -07:00
shadowdaoandClaude Opus 5 d7eeff3a68 feat(site-builder): add in-builder Report an Issue modal
Wires the Task 18 payload builder and Task 16/17 diagnostics into a
user-facing modal, reachable from the topbar bug icon (desktop) and
overflow menu (mobile). Two gaps in the task brief's draft, not called
out there, are handled explicitly: buildReportPayload() can throw when
the payload is still oversized after canvas_state is dropped, so
submission is wrapped in try/catch with an actionable "too large" error
that preserves the user's typed text; and the textarea maxLength is
sourced from MAX_DESCRIPTION_CHARS (with a proximity character count)
instead of a hardcoded number, so the UI limit can't drift from the
payload limit.

Also portals the modal to document.body for the same stacking-context
reasons TemplateModal/HeadCodeModal already do, and guards the
useEditor() selection collector with optional chaining so it degrades
gracefully under TopBar's existing test harness (a minimal @craftjs/core
stub with no events/nodes on its collector state).

The textarea uses onInput rather than onChange: React's onChange dedup
(via its DOM value-tracker) treats a test harness's raw `el.value = x`
assignment as a no-op change, matching this repo's existing pattern
(HeadCodeModal.test.tsx, shared-controls.test.tsx, etc. all work around
the same gotcha) -- onInput is a plain passthrough with no such check,
and is behaviorally identical for real typing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 11:29:26 -07:00
shadowdaoandClaude Opus 5 f43a1ef872 fix(site-builder): enforce the payload cap unconditionally, bound description
Review found the 512KB cap on buildReportPayload only ever measured on the
includeCanvas+canvasState branch -- opt-out and null-canvas paths returned
early without checking size at all, and an oversized non-canvas field
(description straight from a user's textarea) could slip through with a
canvas_state_omitted: 'size' marker that falsely claimed the drop had fixed
things.

- Truncate description to 5000 chars (matches the server-side validator's
  future limit), silently: unlike canvas_state, a truncated free-text
  description is exactly what it looks like, not a misleadingly-plausible
  partial structure.
- Route every return path through finalize(), which measures the actual
  candidate payload and throws rather than returning an oversized body --
  so 'size' can never be attached to a payload that's still over cap.
- Keep the opt-out early return structurally separate so canvas_state is
  never populated from input on that path, regardless of the cap check.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 11:14:12 -07:00
shadowdaoandClaude Opus 5 fd7f883d6a feat(site-builder): add pure issue-report payload builder
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 11:08:46 -07:00
shadowdaoandClaude Opus 5 4cfcccd272 fix(site-builder): swap build-stamp console.log for a window global, add test
Review found two real issues in the initial build-stamp commit:

- A permanent, unconditional console.log on every editor load for every
  customer is production noise. Replaced with a window.__WHP_EDITOR_BUILD__
  assignment -- same load-bearing effect (keeps build-stamp.ts from being
  tree-shaken out before Task 19 wires in the real call site), but prints
  nothing. Support can ask a user to type __WHP_EDITOR_BUILD__ in the
  console on request. Commented as load-bearing so it isn't later "cleaned
  up" as a stray global.
- editorBuild()'s 'dev' fallback was never actually exercised by any test
  in the suite, despite the previous report claiming otherwise. Added
  build-stamp.test.ts asserting editorBuild() === 'dev' under vitest.

Also silences the expected-failure stderr git prints on the successful
'nogit' fallback path (stdio: ['ignore', 'pipe', 'ignore']), so a
release-tarball build log doesn't show a misleading fatal: line for an
intentional, handled case.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 11:05:01 -07:00
shadowdaoandClaude Opus 5 024f9fdd46 feat(site-builder): stamp git sha + date into the editor bundle
package.json's version is hand-maintained and never changes between
builds, so a bug report can't identify which bundle produced it.
Vite's `define` injects __EDITOR_BUILD__ (short git SHA + build date)
at compile time; editorBuild() in build-stamp.ts is the only safe way
to read it, falling back to 'dev' since vitest does not apply Vite's
`define` and the identifier is otherwise undeclared. The execSync
call falls back to 'nogit' when building outside a git checkout
(release tarballs), verified by building from a directory with no
git ancestry at all.

Also wires editorBuild() into a startup console.log in main.tsx --
without any reference to it, Vite tree-shakes the unused module out
of the bundle entirely and __EDITOR_BUILD__ never gets substituted,
silently leaving every bug report saying 'dev'. Task 19 will add the
real call site when it assembles the report payload.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 10:59:38 -07:00
shadowdaoandClaude Opus 5 fc8918c1f9 fix(site-builder): stop the console-error marker from re-deriving "original" from a live wrapper
Review found a gap in the idempotency marker added for Task 16: if
external code wraps our patched console.error between two of our own
installs, the marker sees an unmarked function and treats it as
virgin, capturing the external wrapper itself as "the original".
That both double-records (the old patch is still reachable inside the
wrapper's closure) and makes __resetConsoleErrorBuffer() restore to
the wrapper instead of the real original.

Fix: stash the true original exactly once, directly on the `console`
object (not module scope, so it survives HMR too), and always
re-wrap that stashed reference rather than whatever console.error
currently is. Reinstalling after an external wrap now discards that
wrapper instead of guessing whether it still chains to us -- a
deliberate, documented trade-off, since there is no safe way to tell
those two cases apart from the outside.

Also: window error/rejection listeners now catch exceptions from a
hostile e.reason the same way the console.error patch already did,
and the module doc comment now notes the known HMR buffer-orphan
wrinkle. Adds two tests covering the external-wrapper and
module-re-execution scenarios; both were mutation-verified to fail
against the prior implementation.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 10:51:08 -07:00
shadowdaoandClaude Opus 5 9be71e8fd1 feat(site-builder): capture recent console errors for issue reports
Ring buffer of the most recent console.error/window-error messages
(20 max, 500 chars each, message text only) for Task 19's report
payload. installConsoleErrorBuffer() is idempotent via a marker
stamped on the patched console.error itself (not just a module-scoped
flag), so React 18 StrictMode double-invocation or HMR re-running this
module's top level can't wrap an already-patched console.error and
build a growing chain. The patch always chains to the original.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 10:37:32 -07:00
shadowdaoandClaude Opus 5 aba0d187d7 fix(site-builder): make Reset Entire Site's guard load-bearing in the handler
Two Important review findings on the previous commit (1a01834):

1. handleResetSite ran unconditionally -- the confirm button's `disabled`
   attribute was the only thing standing between a mismatched/empty typed
   value and a full site wipe. Extracted the match check into a single
   exported pure predicate, siteResetConfirmMatches(typed, domain), used
   for the button's disabled/cursor/opacity (previously three duplicated
   inline comparisons) AND as the first line of handleResetSite itself,
   which now returns early if it doesn't hold. An empty domain is rejected
   outright (`!!domain &&` short-circuits) so the guard holds even if the
   handler were ever reached with no configured domain, independent of the
   entry point being hidden.

2. The dialog said "design tokens" but resetToDefaults() also wipes
   headCode (analytics/search-console/third-party scripts) and favicon --
   neither is one of the 17 documented design properties, so a user had no
   reason to read them as included. Copy now names both explicitly.
   Re-verified every remaining claim in the paragraph against what the
   handler actually does (page/header/footer replacement, no undo, no
   publish call, images untouched, 30000ms auto-save) -- all still hold.

Verified load-bearing by temporarily reverting each guard in place (no git
stash -- shared across worktrees/sessions per review feedback) and
confirming the corresponding test fails: dropping the !!domain check broke
the empty-domain unit test; removing the handleResetSite check broke a new
test that invokes the confirm button's React onClick directly (bypassing
both the disabled attribute and react-dom's own disabled-click suppression,
which independent investigation confirmed blocks a plain DOM `.disabled =
false; .click()`/dispatchEvent bypass -- pulling onClick off the element's
stashed __reactProps$ key was the only way to actually exercise the
handler's own guard).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 10:30:20 -07:00
shadowdaoandClaude Opus 5 1a01834068 feat(site-builder): add domain-confirmed Reset Entire Site
Adds a danger-zone escape hatch below SiteDesignPanel's existing "Reset to
Defaults": blanks every page down to one empty Home, blanks the header and
footer, and resets all design tokens. Guarded by typing the exact site
domain to arm the confirm button.

The brief's setHeader('')/setFooter('') calls were wrong -- both take a
SerializedTreeNode (same tree shape as replaceAllPages), not a craft-state
string; passing '' would have hit treeToCraftState's sanitizeAiTree and
silently fallen back to a generic div-shaped empty canvas instead of a
proper header/footer. Built BLANK_HEADER_TREE/BLANK_FOOTER_TREE (tagged
header/footer) alongside the brief's page tree, and dropped the brief's
literal's extraneous flat-state fields (isCanvas/displayName/custom/hidden/
linkedNodes) that made it need an `as any` cast -- sanitizeAiTree/
flattenTreeForCraft only ever read type/props/nodes.

In standalone mode (no WHP_CONFIG) siteDomain is '', so the entire danger
zone -- not just the button -- is hidden behind `siteDomain &&`, closing off
the empty-string-trivially-matches guard bypass.

Dialog copy states the reset is undoable... is NOT undoable, and that
auto-save (confirmed exactly 30s via TopBar.tsx's setInterval) turns the
blank canvas into the saved draft shortly after, so no false safety-net
claim is made.

Verified load-bearing: reverted the implementation via git stash and
confirmed 3 of 4 new tests fail (the 4th, an absence-only standalone-mode
check, passed vacuously on first draft -- rewritten into a same-test
contrast against the non-standalone case, which does fail on revert).
Split into a second mock-free integration test file
(SiteDesignPanel.reset.integration.test.tsx) after vi.mock's file-scoped
hoisting made a same-file vi.doUnmock silently keep using the mocks --
it exercises the real PageProvider/SiteDesignProvider/treeToCraftState
pipeline end to end via the real editorHarness.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 10:17:53 -07:00
shadowdaoandClaude Opus 5 bfcf6278e9 feat(site-builder): add Reset Page to the Pages panel
Adds a "Reset to blank" control to each page row in PagesPanel, behind an
inline confirmation matching the existing delete-confirmation UI. Blanks
the target page's canvas to EMPTY_CANVAS (Task 8) via actions.deserialize.

Since deserialize() acts on the live Frame, resetting a page that isn't
on screen switches to it first (switchPage), then defers the blank via
its own setTimeout(0) -- same-delay setTimeout callbacks fire in
registration order, so switchPage's own deferred load (also setTimeout(0),
registered first) always resolves before the blank does. Verified this
ordering empirically by injecting the reversed-order regression and
confirming the new ordering-sensitive integration test catches it.

Ran the brief's undo-characterization test first: actions.deserialize() IS
recorded in this @craftjs/core version's undo stack, so the confirmation
dialog's "Ctrl+Z undoes this." claim is accurate and was kept.

The reset button lives only in the per-page action row (not the separate
Header/Footer zone-row block), verified structurally and by test.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 10:02:59 -07:00
shadowdaoandClaude Opus 5 e5fd74d63d fix(site-builder): repair orphan nodes on initial page load too
useWhpApi's load() called actions.deserialize() directly on the first
page's stored craftState, bypassing repairOrphanNodes -- unlike
PageContext.loadState, which runs it on every subsequent page switch.
An orphaned node (unreachable from ROOT, invisible to Layers/selection)
in a saved project would get silently repaired on the next page switch
but not on the load that actually renders it first. Route the initial
deserialize through the same repair call, with the same console.warn,
so both paths behave identically.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 09:53:36 -07:00
shadowdaoandClaude Opus 5 cf38fdb245 feat(site-builder): array editors scroll to the item picked in Layers
Wires ArrayItemFieldsEditor and FeaturesEditor up to useLayerFocus() so
clicking a virtual row in the Layers tree scrolls the matching item's
card into view in the right-hand array editor. scrollIntoView is
optional-chained on both the queried element and the method itself so
a miss or an environment without it degrades silently.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 07:21:27 -07:00
shadowdaoandClaude Opus 5 2438777462 feat(site-builder): Layers shows array-prop items, unplaced nodes, and scrolls
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 07:15:17 -07:00
shadowdaoandClaude Opus 5 f9561c8c54 feat(site-builder): add LayerFocusContext for layers-to-array-editor focus
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 07:08:26 -07:00
shadowdaoandClaude Opus 5 f30fc6efec fix(site-builder): add Hero/CTA components to Layers virtual-rows registry
A grep across every component under src/components/ (not just the 12
files the original registry hardcoded) turns up three more leaf
components with the identical array-prop-as-content pattern: Hero
(HeroSimple.tsx), Call to Action (CallToAction.tsx) and CTA Section
(CTASection.tsx) all render a shared `ctas?: CtaButton[]` prop whose
items are `{ text, href, variant?, target? }`. Without this, a Hero's
CTA buttons still wouldn't appear in the Layers tree.

Verified displayName, prop name and label field against each
component's craft.props defaults and the shared CtaButton type in
sections/_cta-helpers.tsx -- all three matched exactly, no corrections
needed. A follow-up sweep for any further array-prop leaf components
found none: the only other array fields in the tree are nested one
level inside already-covered items (ContactFormField.options,
PricingPlan.features), not top-level component props.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 07:04:54 -07:00
shadowdaoandClaude Opus 5 eb4290a45e feat(site-builder): derive Layers rows from array-prop composites
FeaturesGrid, Tabs, Accordion, PricingTable, Testimonials, Gallery,
ContentSlider, NumberCounter, Menu, SocialLinks, Navbar and ContactForm
store their content in array props rather than Craft child nodes, so
the Layers tree showed nothing underneath them. This adds the pure
deriveVirtualRows() function and VIRTUAL_CHILD_PROPS registry the
Layers panel will consume in a later task.

Verified the registry against each component's actual item interface:
corrected Tabs (label field is `label`, not `title` -- TabItem has no
`title`) and Content Slider (label field is `heading`, not `title` --
Slide has no `title`). Both would have silently fallen back to
"Tab N" / "Slide N" for every existing site.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 06:59:23 -07:00
shadowdaoandClaude Opus 5 38c8d22e5d test(site-builder): cover loadState's orphan-repair wiring via PageProvider
Review found Task 8's prescribed test never mounts PageProvider, so it
never exercises loadState -- deleting the repairOrphanNodes call would
still leave that suite green. Adds a PageProvider-mounted test driving
switchPage into a page with a stored orphaned node, asserting on what
loadState hands to actions.deserialize and that console.warn fires.
Verified load-bearing: temporarily neutering the repair call fails the
new test (ROOT.nodes missing 'stray'), then restored.

Also documents the fallback-branch invariant that `fallback` must
always be a known-safe constant, per review.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 06:53:55 -07:00
shadowdaoandClaude Opus 5 9885b37af5 fix(site-builder): reattach unreachable nodes when loading a page
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 06:48:19 -07:00
shadowdaoandClaude Opus 5 86aacbe1a8 fix(site-builder): repair cyclic orphan clusters in repairOrphanNodes
Review found that a cluster of orphan nodes referencing only each other
(no member's parent points outside the orphan set) made the reattach
loop find zero tops and silently no-op, leaving the cluster unreachable
while reporting repaired: []. Replaced the single-pass reattach with a
loop that re-derives the unreachable set each round and force-reattaches
one representative when no ordinary top exists, guaranteeing
findUnreachableNodeIds is empty after repair. Adds 2-node/3-node cycle
and mixed ordinary-subtree-plus-cycle tests; the original 11 tests are
unchanged and still pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 06:43:46 -07:00
shadowdaoandClaude Opus 5 f0a1508acd feat(site-builder): add pure orphan-node detection and repair
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 06:36:39 -07:00
shadowdaoandClaude Opus 5 66db1db507 feat(site-builder): add insert/colour/format toolbar to the Edit HTML modal
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 06:31:55 -07:00
shadowdaoandClaude Opus 5 b670b436c3 fix(site-builder): stop CodeEditor fallback insert from being lost on CodeMirror mount
insertAtCursor's textarea-fallback branch advanced lastEmittedRef to the
post-insertion value. The mount effect's dynamic import() closes over
`value` as of initial render, so if CodeMirror finishes loading after a
fallback-mode insertion, it mounts with the pre-insertion doc. The only
repair mechanism -- the value-sync effect -- is gated on `value !==
lastEmittedRef.current`, so advancing that ref made the gate see them as
already equal and skip the repair, silently dropping the insertion.

Also clamps caretOffset to [0, text.length] defensively, and adds coverage
for a non-empty-selection replace and for the fallback-to-CodeMirror-mount
transition itself (using the real @codemirror/* packages, no mocks).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 06:26:27 -07:00
shadowdaoandClaude Opus 5 d89930e218 feat(site-builder): expose insertAtCursor/getValue handle on CodeEditor
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 19:24:29 -07:00
shadowdaoandClaude Opus 5 45ee004672 fix(site-builder): discriminate formatHtml whitespace by block/inline boundary, not newline
Round 3 fixed a phantom-space idempotency bug by dropping any boundary
whitespace containing a newline, but that also stripped ordinary
hand-wrapped text like "hello\n<strong>", merging words on a single pass --
directly contradicting this formatter's own "does not reflow text"
contract. The correct discriminator is what the whitespace borders, not
whether it contains a newline: a run between two inline-level things (text,
<strong>, <a>, ...) is always significant and must survive regardless of
newlines, while a run touching a block-tag boundary carries no rendered
meaning and is always dropped. Since this formatter's own emitted
indentation is only ever inserted next to a block tag, that rule also
resolves the original phantom-space bug without any newline special-casing.
tokenize() now peeks each upcoming tag's name once (reused for both the
preceding text run's decision and the tag's own processing) so pushTextToken
can see what's on both sides of a whitespace run.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 19:19:32 -07:00
shadowdaoandClaude Opus 5 536e4e9f86 fix(site-builder): make formatHtml close-tag mismatch recovery self-draining
Round 2's tag-name check on close popping fixed misattribution but could
wedge the stack permanently: a mismatch with a real, still-open ancestor
(e.g. an unclosed <p> before a later </div>, a normal optional-end-tag
slip) never drained, so everything after it inherited the stuck depth and
could print out of source order. Close handling now searches the whole
stack for a matching tag, not just the top; frames above a found match are
popped and implicitly closed (no fabricated close tag, just ending their
indentation) before the match itself closes normally. A close with no
match anywhere is still left in place untouched, since it has nothing to
pair with. Also fixes a related idempotency bug in text-run whitespace
collapsing surfaced while verifying this: boundary whitespace containing a
newline (formatter-introduced structural gap) is now dropped entirely
instead of being collapsed to a preserved space like same-line boundary
spaces are.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 19:10:25 -07:00
shadowdaoandClaude Opus 5 51f3fe81b6 fix(site-builder): make formatHtml quote- and raw-text-aware
Code review found two Important bugs from only <pre> being exempted from
the naive </> tag-boundary scan: a > inside a quoted attribute value split
tags and broke idempotency, and <script>/<style> (declared BLOCK_TAGS but
never given raw-text treatment) let JS/CSS < and > desync sibling nesting.
Adds a quote-aware tag-end scanner, generalizes verbatim handling to
<script>/<style>, and makes close-tag stack popping verify the tag name
before popping instead of blindly popping by position.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 18:59:57 -07:00
shadowdaoandClaude Opus 5 321a193b83 feat(site-builder): add dependency-free formatHtml prettifier
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 18:48:41 -07:00
shadowdaoandClaude Opus 5 4ac57e1c4c feat(site-builder): HTML blocks get a code-only style panel
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 18:42:28 -07:00
shadowdaoandClaude Opus 5 0d0d722dd7 refactor(site-builder): extract HtmlCodeField out of GenericPropsEditor
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 18:38:38 -07:00
shadowdaoandClaude Opus 5 a30e82accf fix(site-builder): HtmlBlock render stops applying style so editor matches published output
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 18:35:12 -07:00
shadowdaoandClaude Opus 5 6791345f77 docs: implementation plan for the five user-reported site-builder issues
23 TDD tasks across five phases: HTML block (render/export mismatch, code-only
panel, editor toolbar), tree integrity (orphan repair + Unplaced recovery),
reset page/site, Layers virtual rows for array-prop composites, and in-builder
issue reporting (endpoint, table, root-only admin page). Off-canvas drop
prevention is scoped as an investigation whose acceptance criterion is a
written mechanism, not a guessed fix.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 18:27:02 -07:00
shadowdaoandClaude Opus 5 fb68fa6485 docs: design spec for five user-reported site-builder issues
Covers off-canvas drops (reachability invariant: prevent, repair on load,
recover via Layers), the HTML block's dead style props (render/export
mismatch plus a code-editor toolbar), page/site reset, Layers virtual rows
for array-prop composites, and in-builder issue reporting backed by a new
whp.site_builder_reports table and a root-only admin page.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 10:39:00 -07:00
shadowdaoandClaude Opus 5 c6840db0bb chore(branding): rename "Web Hosting Panel" to "Web Hosting Platform"
Matches the rename in the whp repo now that the Web Hosting Platform
domain is registered. Doc line and a JS comment; no behaviour change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 14:17:27 -07:00
jknapp f5d2a23a5f Merge PR #26: surface publish warnings in the editor 2026-07-16 12:34:32 +00:00
shadowdaoandClaude Opus 4.8 c712a69c4a test(topbar): cover publish() warnings wiring into PublishWarnings banner
PublishWarnings.tsx had unit tests for the presentational banner, but
nothing asserted that result.warnings from publish() actually flows
through TopBar's handlePublish into it. That 3-line seam is exactly what
this feature exists to fix -- the backend always returned warnings, and
TopBar discarded them by only checking result.success, so the
contact-form-relay warning was dead code for its entire life.

Adds TopBar.test.tsx asserting: warnings render after a successful
publish with warnings, no banner renders when warnings is absent, a
warning doesn't present as a publish failure, warnings survive the 3s
"Published" flash (fake timers, advanced past 3000ms), and a fresh
publish clears stale warnings from the previous one.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 22:12:31 -07:00
shadowdaoandClaude Opus 4.8 d460e8ac33 topbar: surface publish warnings instead of discarding them
handlePublish's JSON response has always included a `warnings` array
(e.g. the contact-form relay's "submissions will not be delivered"
notice), but nothing in the editor ever read it. Adds a PublishWarnings
banner, held in its own state independent of the 3s publishStatus
flash so the customer has time to read it, rendered in both the
desktop and mobile TopBar branches.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 21:52:11 -07:00
jknapp 2b1569202a Merge PR #25: bounce + image crop/resize fix 2026-07-14 23:07:58 +00:00
shadowdaoandClaude Opus 4.8 5c44dd545c fix(site-builder): bounce stays visible + springier; image/video crop fills (cover) + resize shrinks footprint
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 16:04:50 -07:00
jknapp 2ac62c4e9e Merge PR #24: fix animation delay unit 2026-07-14 19:36:51 +00:00
shadowdaoandClaude Opus 4.8 25dfcbb725 fix(site-builder): coerce bare-number animation delay to a valid CSS time (2 -> 2s)
data-animation-delay is stored as a plain seconds string (e.g. '2'); the reveal
script assigned it raw to el.style.animationDelay, which is invalid CSS and no-ops.
Suffix 's' onto bare numbers (leaving '2s'/'200ms' alone) so entrance-animation
delays actually apply. Backend generateCompiledHTML gets the byte-identical change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 12:34:51 -07:00
jknapp 9bf78fd72d Merge PR #23: fix entrance-animation output 2026-07-14 19:16:59 +00:00
shadowdaoandClaude Opus 4.8 2dcc2b4d21 fix(site-builder): entrance-animation reveal script survives Preview + well-formed void-tag attrs + no-JS fallback
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 12:12:01 -07:00
jknapp 4e0fc78a30 Merge PR #22: enh pages productivity + cross-page clipboard 2026-07-14 14:48:25 +00:00
shadowdaoandClaude Opus 4.8 85dfe181aa fix(pages): duplicatePage must save outgoing active canvas before teardown (avoid dropping live edits)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 07:47:44 -07:00
shadowdaoandClaude Opus 4.8 a698f014b0 feat(site-builder): page duplicate/reorder/set-landing + fix cross-page node copy/paste
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 07:36:00 -07:00
jknapp 204ea5e078 Merge PR #21: enh output/seo/tokens (frontend) 2026-07-14 14:21:10 +00:00
shadowdaoandClaude Opus 4.8 0291ddce9a feat(site-builder): per-page SEO meta + favicon + design-token CSS-var wiring + published-output a11y/perf
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 07:14:56 -07:00
jknapp f7da654c11 Merge PR #20: enh nav 2026-07-14 13:58:11 +00:00
jknapp d6666f6f79 Merge PR #19: enh containers 2026-07-14 13:56:11 +00:00
shadowdaoandClaude Opus 4.8 4a426e3513 fix(containers): only flex-convert Container/Section when vertical-align set (avoid blockifying inline-block children)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 06:55:20 -07:00
jknapp 6e628d68dd Merge PR #18: enh sections 2026-07-14 13:54:52 +00:00
jknapp 8f17b74e2b Merge PR #17: enh forms 2026-07-14 13:49:57 +00:00
shadowdaoandClaude Opus 4.8 54572f648a feat(builder): nav/menu link-to-page picker, page sync, download attr, box-model rollout
NavStylePanel (Navbar/Menu/Logo/Footer):
- LinkPicker: dropdown of the site's pages (read-only via usePages()) plus
  manual URL / #anchor / tel: / mailto: entry, wired into every link-href
  field (standalone Logo href, Navbar logoUrl, Navbar/Menu link items).
- "Sync links with Pages" button in the Links section: repopulates the
  links array from the current pages list (label = page name, href = '/'
  for the landing page else '/{slug}'), preserving any existing CTA link.
  Regression-fix vs the legacy GrapesJS builder, which had this.
- `download` checkbox per link (Navbar/Menu links, standalone Logo href)
  emits the `download` attribute on export for links to files.
- Links/Colors sections now gate on the component actually carrying a
  `links`/color prop, so Footer (no links array) no longer shows a dead
  "Add Link" editor.
- Box-model (Margin/Padding via SpacingControl, Border & Effects via
  BorderControl + box-shadow presets + opacity), AnimationControl, and
  VisibilityControl added for all four owned components, backed by new
  animation/animationDelay/hideOnDesktop/hideOnTablet/hideOnMobile props
  (with blank/default values in each component's .craft.props).

Tests: NavStylePanel.test.tsx (new, 17 tests: LinkPicker modes, sync
preserves CTA, download toggle, box-model/animation/visibility wiring) +
extended Navbar/Menu/Logo/Footer .toHtml.test.ts (download attribute,
craft.props defaults). Full suite: 683/683 passing. `npm run build` green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 06:47:59 -07:00
shadowdaoandClaude Opus 4.8 8bf525c600 feat(builder): surface hidden section/pricing/social props + box-model rollout
SectionTypePanel (Accordion/Tabs/Testimonials/Countdown/NumberCounter/
CTASection/CallToAction/FeaturesGrid), PricingStylePanel, and
SocialStylePanel all gain a Spacing & Border section (margin/padding
per-side, border, box-shadow, opacity), an Animation section, and a
Visibility section, wired to the shared SpacingControl/BorderControl/
AnimationControl/VisibilityControl.

PricingTable's per-card colors (cardBg/textColor/subColor/featColor/
checkColor/btnBg/btnColor) were previously hard-coded literals computed
from featuredBg inside toHtml -- promoted to real optional props (each
falling back to the exact prior literal when unset) and exposed via
ColorPickerField in PricingStylePanel.

SocialStylePanel now exposes SocialLinks' iconShape/gap (already-built
props with no control), plus Icon's bgColor/bgShape/bgSize/link and
StarRating's filledColor/emptyColor, which the panel's generic
iconBgColor/starColor checks never matched since those aren't Icon's or
StarRating's real prop names.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 06:47:58 -07:00
jknapp d07bc7789d Merge PR #16: enh code editor 2026-07-14 13:47:46 +00:00
jknapp b03425ac39 Merge PR #15: enh text+button 2026-07-14 13:46:08 +00:00
shadowdaoandClaude Opus 4.8 9750a6c2bf feat(builder): containers package -- vertical alignment + box-model/anim/vis rollout
- ColumnLayout: exposes style.alignItems on its flex ROW (aligns uneven
  columns) -- render/toHtml already spread `style` onto the row div, so this
  is a craft.props default + panel control addition, no structural change.
- Container/Section: root element is now unconditionally display:flex;
  flex-direction:column (both editor render and toHtml), so the new
  Vertical Alignment control maps to style.justifyContent, paired with a
  Min Height (NumericUnitInput) control on style.minHeight. Default
  justify-content/align-items reproduce ordinary block-flow stacking, so
  this is a visual no-op for existing published content. Works in both
  normal and "boxed" (contentWidth) modes -- the boxed inner wrapper's own
  margin:0-auto horizontal centering is preserved via flex auto-margin
  override semantics.
- ContainerStylePanel (serves Container/Section/Columns) distinguishes the
  Columns case from Container/Section via nodeProps.columns/split presence
  (no typeName plumbing needed) to pick align-items vs justify-content for
  the shared Vertical Alignment control.
- All 3 owned components: added margin/padding (per-side)/border/box-shadow/
  opacity style defaults + AnimationControl/VisibilityControl-backed
  animation/animationDelay/hideOnDesktop/hideOnTablet/hideOnMobile props.
  New containerBoxModel.tsx (package-local, not shared.tsx) DRYs the
  box-model + border/effects + animation/visibility panel sections across
  the single shared ContainerStylePanel, mirroring the sibling media
  package's mediaBoxModel.tsx.
- Tests: extended all 3 *.toHtml.test.ts files (align-items/justify-content/
  min-height emission, box-model style emission, craft.props presence).
  673 tests green, tsc + vite build clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 06:44:37 -07:00
shadowdaoandClaude Opus 4.8 5b19ae97af feat(builder): FORMS package -- field editor, functional Subscribe/Search, box-model+anim rollout
- FormStylePanel: ContactForm field editor (add/remove/reorder via
  ArrayPropEditor + manual move up/down) covering label/name/placeholder/
  type (full sanitizeInputType allowlist + textarea/select)/required/
  options.
- SubscribeForm.toHtml: was a dead `<form method="POST">` with no action at
  all -- wired through the same relayFormWiring contract as ContactForm/
  FormContainer so a recipientEmail makes it actually submit (marker +
  placeholder action + honeypot), falling back to action="#" otherwise.
- SearchBar: was purely decorative (no action/method/input name) -- now a
  real GET form (configurable target, default "/") with input name="q",
  safeUrl-guarded against javascript:/vbscript: breakout.
- Box-model (margin/padding per-side, border, shadow, opacity), entrance
  animation, and hide-on-device controls added to FormStylePanel and
  rolled out (blank/false craft.props defaults) across ContactForm,
  FormContainer, InputField, TextareaField, FormButton, SubscribeForm,
  SearchBar. No toHtml changes needed for animation/visibility --
  html-export.ts's buildDataAttrs() already emits data-animation/
  data-hide-* generically from these prop names.
- Extended toHtml tests for all 7 components: field-type rendering
  (incl. textarea/select), type-attribute XSS sanitization, relay/GET
  functional wiring, box-model style passthrough, craft.props defaults.

npx vitest run: 689/689 passed. npm run build: green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 06:44:21 -07:00
shadowdaoandClaude Opus 4.8 88df4f2888 feat(builder): CodeMirror 6 code editor for HTML/CSS/JS with lazy loading
Replaces the plain single-line input (HtmlBlock's `code` prop, via
GenericPropsEditor) and the plain <textarea> (HeadCodeModal's site-wide
head code) with a proper syntax-highlighted, tab-completing code editor.

- New src/ui/CodeEditor.tsx: reusable CodeMirror 6 editor (state/view/
  commands/autocomplete/language/lang-html/lang-css/lang-javascript/
  theme-one-dark). All @codemirror/* packages are pulled in via a single
  dynamic import() inside the component so they land in separate lazy
  chunks instead of the main bundle -- confirmed via `npm run build`:
  main editor.js grew by only ~5.5KB (657KB -> 663KB raw) while ~570KB of
  CodeMirror source split into index*.js chunks that only load when a
  code editor modal is actually opened. While that import is in flight,
  or if it ever fails, the component renders a plain <textarea> so typing
  never breaks.
- GenericPropsEditor.tsx: special-cases the `code` prop (used only by
  HtmlBlock today) into an "Edit HTML" button that opens the CodeEditor
  in a modal (language="html"), instead of rendering it as a single-line
  text input alongside the component's other string props.
- HeadCodeModal.tsx: swaps its <textarea> for CodeEditor (language="html"
  -- head code is HTML with embedded <script>/<style>), keeping the
  existing SiteDesignContext.updateDesign({ headCode }) wiring.

GuidedStyles.tsx untouched: HtmlBlock's displayName ("HTML") already
matches GuidedStyles' `isUtility` regex and routes to GenericPropsEditor,
so no dispatcher change was needed.

HtmlBlock.tsx untouched: purifyHtml/toHtml sanitization is unchanged, as
specified. Skipped the optional AnimationControl/VisibilityControl
addition -- HtmlBlock has no dedicated StylePanel (it shares
GenericPropsEditor with Divider/Spacer/every unmatched type), and doing
it well would mean adding real Animation/Visibility widgets to that
shared editor for every consumer, which is a bigger change than "trivial"
for this package's scope.

Tests: CodeEditor.test.tsx and HeadCodeModal.test.tsx both exploit the
fact that dynamic import() always resolves on a later microtask, so
asserting against the DOM immediately after the initial synchronous
render deterministically exercises the <textarea> fallback path (value
display, onChange wiring, language prop plumbing) without needing to
mock @codemirror/*.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 06:42:59 -07:00
shadowdaoandClaude Opus 4.8 e1b4ab735c feat(builder): text+button package -- typography depth, button target/hover, box-model+anim/vis rollout
- TextStylePanel (Heading/TextBlock): line-height/letter-spacing presets,
  text-transform (none/uppercase/lowercase/capitalize), italic + underline
  toggles (fontStyle/textDecoration), and a custom NumericUnitInput
  font-size alongside the existing preset row. All write to component
  `style`; no toHtml changes needed (style already flows through
  cssPropsToString for both components).
- ButtonStylePanel/ButtonLink: "Open in new tab" checkbox writes the
  existing `target` prop ('_self'/'_blank' -- toHtml already emitted
  rel="noopener noreferrer" for _blank). New Hover State section
  (hoverBg/hoverColor via ColorPickerField) renders a scoped
  `<style>.btn_<hash>:hover{...}</style>` block before the `<a>` in
  toHtml, scoped per-node via scopeId (same pattern as Navbar/Menu) so
  two buttons on one page don't collide; both values sanitized through
  cssValue against <style>-element breakout. Editor canvas gets a live
  hover preview via onMouseEnter/onMouseLeave local state (mirrors Menu's
  approach), since there's no way to preview a CSS :hover rule directly
  on an inline-styled React element.
- Heading/TextBlock/ButtonLink: added margin(per-side)/padding(per-side,
  Text only)/border/box-shadow/opacity style defaults + AnimationControl/
  VisibilityControl-backed animation/animationDelay/hideOnDesktop/
  hideOnTablet/hideOnMobile props, each panel gaining collapsible
  Spacing / Border & Effects / Animation & Visibility sections. Button
  keeps its existing padding-shorthand preset row rather than adding a
  redundant per-side padding control; only margin got the new
  per-side SpacingControl.
- Tests: extended all three *.toHtml.test.ts files -- typography style
  emission (line-height/letter-spacing/text-transform/font-style/
  text-decoration/custom font-size), button target+rel, scoped hover
  style emission + two adversarial style-breakout cases (</style><script>
  and rule-injection via `;}selector{`), and craft.props assertions for
  every new prop on all three components. 674 tests green, tsc + vite
  build clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 06:41:53 -07:00
jknapp ff1e8fc096 Merge PR #14: enh media package 2026-07-14 13:37:55 +00:00
shadowdaoandClaude Opus 4.8 0419291259 feat(builder): media package -- image crop/perf, video size+picker, gallery cols/lightbox, box-model+anim/vis rollout
- ImageBlock/ImageStylePanel: SizeControl width(absorbs old maxWidth
  presets)/height, AspectRatioControl + OBJECT_FIT grid + FocalPointGrid for
  CSS framing crop (aspect-ratio/object-fit/object-position on the <img>).
  Exported <img> always gets loading="lazy" decoding="async", plus width/
  height attrs when the style has a plain px length (pxAttr helper).
- VideoBlock/MediaStylePanel: Video URL text input replaced with
  AssetPicker(mediaType="video") writing videoUrl (paste-URL still handles
  YouTube/Vimeo, upload/browse handle files). Added SizeControl(width) +
  AspectRatioControl so the video frame honors real size/aspect instead of
  a hardcoded 16:9 (padding-bottom hack replaced with CSS aspect-ratio).
  New optional `poster` prop (image AssetPicker) + preload="metadata" on
  file-type <video>.
- Gallery: surfaced the existing-but-unexposed `columns` and `lightbox`
  props with panel controls.
- All 5 owned components (ImageBlock, VideoBlock, Gallery, ContentSlider,
  MapEmbed): added margin/padding (per-side)/border/box-shadow/opacity style
  defaults + AnimationControl/VisibilityControl-backed animation/
  animationDelay/hideOnDesktop/hideOnTablet/hideOnMobile props. New shared
  mediaBoxModel.tsx (package-local, not shared.tsx) DRYs the box-model +
  border/effects + animation/visibility panel sections across
  ImageStylePanel and MediaStylePanel.
- Tests: extended *.toHtml.test.ts for all 5 components (crop/perf attrs,
  video size/aspect/poster/preload, gallery columns/lightbox, box-model
  style emission, craft.props presence) + new MediaStylePanel.video.test.tsx
  verifying the AssetPicker wiring writes videoUrl/poster and the video-only
  size controls are gated on videoUrl. 694 tests green, tsc + vite build
  clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 06:30:50 -07:00
jknapp 1d9460c173 Merge PR #13: enhancement foundation 2026-07-14 13:12:22 +00:00
shadowdaoandClaude Opus 4.8 8d69982a0b chore(builder): install CodeMirror deps for the upcoming code-editor feature
Adds @codemirror/state, view, commands, autocomplete, language,
lang-html, lang-javascript, lang-css, and theme-one-dark to package.json
now (unused for now, tree-shaken out of the build) so the code-editor
feature branch doesn't need to touch package.json/package-lock.json itself
and conflict with other enh-batch branches doing the same.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 06:07:25 -07:00
shadowdaoandClaude Opus 4.8 fb40e3ece4 feat(builder): add reusable StylePanel controls (foundation for enh batch)
Adds NumericUnitInput, SizeControl, AspectRatioControl, FocalPointGrid,
SpacingControl, BorderControl, AnimationControl, and VisibilityControl to
src/panels/right/styles/shared.tsx -- presentational value/onChange controls
that upcoming feature panels will import instead of reinventing size,
spacing, border, and animation/visibility UI per panel.

AnimationControl/VisibilityControl emit the exact prop names
(animation/animationDelay, hideOnDesktop/hideOnTablet/hideOnMobile) already
consumed by html-export.ts's buildDataAttrs(), verified by reading that file
directly. ANIMATIONS matches the export's actual data-animation set
(fade-in/slide-up/slide-left/slide-right/zoom-in/bounce) rather than the
slide-down variant that doesn't exist in the export.

No existing shared.tsx exports were touched, and nothing is wired into any
feature panel yet -- that's the downstream feature branches' job.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 06:07:20 -07:00
shadowdaoandClaude Opus 4.8 9591fcb8a7 feat(builder): add shared style presets for size/aspect-ratio/shadow/typography rollout
ASPECT_RATIOS, SHADOW_PRESETS, LINE_HEIGHTS, LETTER_SPACINGS, OBJECT_FIT,
BORDER_STYLES, and SIZE_PRESETS -- foundation for the new shared StylePanel
controls (SizeControl, AspectRatioControl, BorderControl) so upcoming
feature panels share one preset source instead of each defining their own.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 06:07:14 -07:00
jknapp f80811318b Merge PR #12: real-Editor integration test harness 2026-07-14 00:12:49 +00:00
shadowdaoandClaude Opus 4.8 cb9fea6656 test(builder): real-@craftjs/core integration test harness (duplicate/paste, template load, AI apply-response)
Mocked unit tests for regenerateTreeIds, TemplateModal.addTemplateComponents,
and buildNodeTree each let a real bug ship (DataCloneError on duplicate/paste,
dropped template children, no-op AI section-replace/insert) because their
fake @craftjs/core query/actions never exercised Craft's real node shape
(data.type as a live component reference) or real parseFreshNode validation.

Adds src/test-utils/editorHarness.tsx, which mounts a REAL <Editor>+<Frame>
(no vi.mock('@craftjs/core') anywhere) via react-dom/client + act, plus the
jsdom shims Craft/this component library actually needs (ResizeObserver,
matchMedia, and an HTMLElement.prototype.innerText polyfill -- jsdom has no
native innerText, which Heading/TextBlock rely on to paint their text).

Adds 3 integration suites under src/test-utils/integration/ driving the real
useNodeActions/useKeyboardShortcuts, TemplateModal's real tree-build pipeline,
and the real useApplyAiResponse hook against a live EditorStore, asserting on
both the real rendered DOM and query.serialize()/exportBodyHtml output.

Red-proofed the duplicate/paste suite: temporarily reverted
regenerateTreeIds to structuredClone(oldNode.data) and confirmed both tests
fail with the historical DataCloneError before restoring the fix.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 17:11:40 -07:00
jknapp b1afdcb585 Merge PR #11: mobile fast-follows 2026-07-13 16:19:53 +00:00
shadowdaoandClaude Opus 4.8 f00e4db3dc test(builder): cover useVisualViewport; fix: disable Select-Parent at ROOT in desktop context menu
- useVisualViewport.test.tsx: covers the visualViewport-undefined fallback,
  the keyboardInset math on a mocked visualViewport resize, and
  listener add/remove (resize + scroll) across mount/unmount.
- ContextMenu.tsx: Select Parent was only disabled at isRoot, so selecting
  a top-level section and choosing Select Parent silently landed on the
  un-editable ROOT (no outline, no toolbar) -- a dead end. Switched the
  guard to useNodeActions' canSelectParent (false whenever the node's
  parent is ROOT or missing), matching the mobile selection toolbar's
  existing identical guard.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 09:19:00 -07:00
jknapp b9552faa30 Merge PR #10: Mobile Phase B (touch editing) 2026-07-13 15:43:29 +00:00
shadowdaoandClaude Opus 4.8 77f35c4e9e fix(builder): duplicate inserts+selects after source; select-parent/styles-sheet/canvas-pad mobile fixes
Ship-blocking fix (Fable consult): useNodeActions.duplicate() appended the
regenerated tree at the end of the parent while leaving the ORIGINAL
selected, so duplicating a top-level section landed the copy off-screen at
the bottom of the page with no visible change -- shared by both the mobile
selection toolbar and the desktop right-click ContextMenu. Now inserts the
copy immediately after the source (actions.addNodeTree(tree, parentId,
sourceIndex + 1)) and selects it (actions.selectNode(tree.rootNodeId)),
falling back to append-at-end if the source's index can't be resolved.
Mobile also scrolls the new node into view.

Three cheap fast-follows:
- canSelectParent on useNodeActions (false when the node's parent is ROOT
  or missing); MobileSelectionToolbar disables "Select Parent" instead of
  dead-ending on a page-wide ROOT outline with no toolbar of its own.
- Opening the Styles sheet on mobile now scrolls the selected node above
  the 65dvh sheet; a temporary generous bottom-padding class handles the
  case where the node is the last thing on the page and there'd otherwise
  be no room left to scroll it into view.
- Canvas gets bottom padding equal to the fixed selection toolbar's height
  while it's visible, so the last section of a short page isn't stuck
  permanently underneath it.

Desktop duplicate behavior improves (inserts after + selects) via the
shared hook; no toHtml changes. Verified live via Playwright at 375px and
1280px (screenshots in craft/scratchpad/mobileB2/).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 08:41:33 -07:00
shadowdaoandClaude Opus 4.8 2c8425ffb0 feat(builder): mobile-B touch editing -- selection toolbar, tap-to-add, swipe-dismiss
Phase B makes the Craft.js editor genuinely usable by touch on top of Phase
A's responsive shell, gated entirely behind useIsMobile()/<=768px:

- Extract useNodeActions(nodeId) out of ContextMenu.tsx (move/duplicate/
  delete/select-parent), shared by the desktop right-click menu (behavior
  unchanged) and the new mobile MobileSelectionToolbar.
- MobileSelectionToolbar: bottom-fixed selection toolbar (Move Up/Down,
  Duplicate, Select Parent, Edit Styles, two-tap Delete confirm), hidden
  while a sheet is open.
- BlocksPanel: tap-to-add on mobile (insert after selection, close sheet,
  select + scroll the new node into view); desktop drag/double-click
  unchanged.
- LayersPanel rows >=44px on mobile; HeadCodeModal portaled to document.body
  (same fix TemplateModal already had); BottomSheet gets swipe-to-dismiss
  and on-screen-keyboard clearance via a new useVisualViewportInsets hook.

Also fixes two pre-existing bugs surfaced only by driving a real Craft.js
document with Playwright touch input (masked by tests that mock
@craftjs/core): regenerateTreeIds structuredClone'd a live node's whole
data object, including the component function reference in data.type,
throwing DataCloneError and silently breaking Duplicate/Paste for every
node type; and an earlier useNodeActions draft cached canMoveUp/canMoveDown
inside a useEditor collector closed over nodeId, which goes stale for one
render whenever the selection changes without an unrelated store event.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 08:07:51 -07:00
jknapp 2a071bc7ab Merge PR #9: Mobile Phase A 2026-07-13 14:31:23 +00:00
shadowdaoandClaude Opus 4.8 2a8a26687b fix(builder): mobile-A2 hardening -- 16px inputs, shared sheet/modal chrome, z-scale + portal
- Force font-size:16px !important on Styles-sheet/topbar/Sitesmith inputs
  inside the mobile media query so inline 12px/14px styles stop triggering
  iOS zoom-on-focus.
- Lift sheet-open + Templates/Head Code modal-open state out of private
  useState into a shared MobileChromeContext (EditorShell), so Phase B can
  open/close sheets from outside MobilePanelBar.
- Add an explicit z-index layer scale, portal TemplateModal to
  document.body (was trapped under the tab bar inside .topbar's stacking
  context), align Sitesmith to the same --z-modal layer, and make opening
  a sheet close any open modal. Also fix modal backdrops swallowing tab
  bar taps (mirrors the sheet backdrop's existing tab-bar cutout).
- Drop BottomSheet's incorrect aria-modal; mobile-aware AssetsPanel empty
  state copy.
- Tests: useIsMobile (matchMedia mock incl. legacy fallback + cleanup),
  MobileChromeContext invariants (one sheet open, sheet closes modals),
  MobilePanelBar wiring.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 07:30:12 -07:00
shadowdaoandClaude Opus 4.8 979331b12d feat(builder): mobile-responsive editor chrome (Phase A)
Makes the Craft.js editor usable on phones (≤768px) without touching desktop
layout/behavior: a useIsMobile() hook gates a bottom tab bar + sheets (hosting
the existing Blocks/Pages/Layers/Assets/Styles panels unchanged) in place of
the side panels, a collapsed TopBar with a "..." overflow menu, 44px touch
targets, 16px inputs, dvh/safe-area-aware sizing, and small copy/overflow
fixes (empty-canvas hint, Templates modal tabs + close button).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 06:54:15 -07:00
jknapp 329a782052 Merge PR #8: fix templates dropping nested content 2026-07-13 13:28:39 +00:00
shadowdaoandClaude Opus 4.8 da558fd52d fix(builder): render template header/footer nested content in zone preview + export
TemplateModal's addTemplateComponents() built each template component via
React.createElement(Component, comp.props) without ever passing
comp.children, silently dropping every nested children array authored in
templates/definitions.ts (header/footer Container > Logo/Menu/TextBlock,
page Section > Heading/TextBlock/ButtonLink). The resulting Craft.js node had
nodes: [], so the header/footer zone preview (ZonePreview -> exportBodyHtml)
rendered as an empty strip, and published output was affected the same way.

Fix converts each TemplateComponent to a SerializedTreeNode and reuses
craft-tree.ts's buildNodeTree (sanitize -> flatten -> materialize) -- the
same tested tree pipeline already used for AI-generated content -- instead
of hand-rolling a React-element tree, since a naive nested-children fix via
parseReactElement crashes any component with an internal SHELL_INNER linked
canvas (Section/BackgroundSection/FormContainer) or linked columns
(ColumnLayout). Also fixes two latent bugs in buildNodeTree itself, only
surfaced by exercising it against a real Craft.js editor for the first time:
data.type must be the actual resolved component reference (not a string or
{resolvedName} object) for correct rendering, and the synthesized SHELL_INNER
node needs data.name set for actions.addNodeTree's own validation.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 06:19:26 -07:00
jknapp 8aeadefa88 Merge PR #7: UI polish Phase 2 2026-07-13 04:13:17 +00:00
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
230 changed files with 29145 additions and 1315 deletions
+1 -1
View File
@@ -2,7 +2,7 @@
## Overview
This site builder integrates with WHP (Web Hosting Panel) to provide users with a visual site building interface. Users can create HTML pages using a drag-and-drop editor and save them directly to their web hosting account.
This site builder integrates with WHP (Web Hosting Platform) to provide users with a visual site building interface. Users can create HTML pages using a drag-and-drop editor and save them directly to their web hosting account.
## Architecture
+58 -46
View File
@@ -41,14 +41,12 @@ craft/
│ │ └── Canvas.tsx # Craft.js <Frame> with device-width switching
│ │
│ ├── components/
│ │ ├── resolver.ts # Component map for Craft.js serialization (20 components)
│ │ ├── resolver.ts # Component map for Craft.js serialization (39 components)
│ │ ├── layout/
│ │ │ ├── Container.tsx # Generic container (div/section/article/header/footer/main)
│ │ │ ├── Section.tsx # Full-width section with centered inner container
│ │ │ ├── ColumnLayout.tsx # Flex columns (1-6, with split ratios)
│ │ │ ├── BackgroundSection.tsx # Section with background image/gradient overlay
│ │ │ ├── HeaderZone.tsx # Page-level header zone wrapper
│ │ │ └── FooterZone.tsx # Page-level footer zone wrapper
│ │ │ └── BackgroundSection.tsx # Section with background image/gradient overlay
│ │ ├── basic/
│ │ │ ├── Heading.tsx # Inline-editable heading (h1-h6)
│ │ │ ├── TextBlock.tsx # Inline-editable paragraph
@@ -73,7 +71,8 @@ craft/
│ ├── panels/
│ │ ├── topbar/
│ │ │ ├── TopBar.tsx # Back button, domain badge, device switcher, undo/redo, save, templates
│ │ │ └── TemplateModal.tsx # Template browser with categories and one-click loading
│ │ │ ├── TemplateModal.tsx # Template browser with categories and one-click loading
│ │ │ └── HeadCodeModal.tsx # Edits SiteDesign.headCode (site-wide, not per-page)
│ │ ├── left/
│ │ │ ├── LeftPanel.tsx # Tabs: Blocks | Pages | Layers | Assets
│ │ │ ├── BlocksPanel.tsx # Draggable block toolbox with categories
@@ -81,9 +80,10 @@ craft/
│ │ │ ├── LayersPanel.tsx # Component hierarchy tree view
│ │ │ └── AssetsPanel.tsx # Asset browser with upload, drag-drop, thumbnails
│ │ ├── right/
│ │ │ ├── RightPanel.tsx # Tabs: Styles | Settings | Head
│ │ │ ├── GuidedStyles.tsx # Context-aware style panel (shows selected type)
│ │ │ └── SiteDesignPanel.tsx # Site-wide design tokens editor (Basic/Advanced tabs)
│ │ │ ├── RightPanel.tsx # Single "Styles" tab -- renders GuidedStyles only
│ │ │ ├── GuidedStyles.tsx # Context-aware dispatcher: picks a StylePanel by selected type
│ │ │ ├── SiteDesignPanel.tsx # Site-wide design tokens editor (Basic/Advanced tabs)
│ │ │ └── styles/ # Per-type StylePanels (Text, Button, Image, Container, Nav, Form, etc.)
│ │ └── context-menu/
│ │ └── ContextMenu.tsx # Right-click context menu (duplicate, copy, paste, delete, etc.)
│ │
@@ -98,7 +98,8 @@ craft/
│ │ └── definitions.ts # 16 template definitions across 4 categories
│ │
│ ├── ui/
│ │ └── SettingsTabs.tsx # Reusable General/Style/Advanced tabs for component settings
│ │ ├── AssetPicker.tsx # Reusable image/video source picker (upload/browse-uploaded/URL), full+compact variants
│ │ └── Modal.tsx # Reusable modal dialog shell
│ │
│ ├── constants/
│ │ └── presets.ts # Color, font, spacing, radius, gradient, device width presets
@@ -165,7 +166,7 @@ The PHP wrapper (`/docker/whp/web/site-builder/index.php`) injects `WHP_CONFIG`
5. **API compatibility** - The save endpoint sends data in the same format as the GrapesJS version (`{ site_id, name, html, css, grapesjs: serializedJson }`), so the PHP backend doesn't need changes.
6. **Component-based architecture** - Each visual element is a React component that doubles as a Craft.js `UserComponent`. All rendering, settings UI, and HTML export are co-located in one file.
6. **Component-based architecture** - Each visual element is a React component that doubles as a Craft.js `UserComponent`. Rendering and HTML export are co-located in one file; style editing is handled by a shared per-type `StylePanel` in `src/panels/right/styles/` rather than a per-component settings panel.
7. **Site Design Tokens** - A `SiteDesignContext` provides 17 design properties (colors, fonts, radii, nav style) that components can reference. Templates import their own design tokens when loaded.
@@ -188,28 +189,21 @@ export const MyComponent: UserComponent<MyComponentProps> = ({ text, style }) =>
return <div ref={(r) => { if (r) connect(drag(r)); }} style={style}>{text}</div>;
};
// 3. Settings panel (rendered in right panel when selected)
const MyComponentSettings: React.FC = () => {
const { actions: { setProp }, props } = useNode((node) => ({
props: node.data.props as MyComponentProps,
}));
return <div>/* preset buttons, inputs, etc. */</div>;
};
// 4. Craft config (displayName, default props, rules, related settings)
// 3. Craft config (displayName, default props, rules)
MyComponent.craft = {
displayName: 'My Component',
props: { text: 'Default text', style: {} },
rules: { canDrag: () => true, canMoveIn: () => false, canMoveOut: () => true },
related: { settings: MyComponentSettings },
};
// 5. HTML export (static method for serializing to HTML string)
// 4. HTML export (static method for serializing to HTML string)
(MyComponent as any).toHtml = (props: MyComponentProps, childrenHtml: string) => {
return { html: `<div style="...">${childrenHtml}</div>` };
};
```
Style editing for the new type is added separately as a `StylePanel` under `src/panels/right/styles/` (or reuses an existing generic one), and wired into `GuidedStyles.tsx`'s type dispatch -- components no longer carry their own settings UI.
### Component Resolver
All components must be registered in `src/components/resolver.ts`. This map is passed to `<Editor resolver={componentResolver}>` so Craft.js can serialize/deserialize the node tree.
@@ -270,7 +264,7 @@ The editor auto-saves every 30 seconds when running inside WHP. The save status
- `whpConfig` - The full config object (or null in standalone mode)
- `isWHP` - Boolean shorthand for whether we're running inside WHP
## All Components (22)
## All Components (39)
| # | Component | Type | File | Features |
|---|-----------|------|------|----------|
@@ -278,24 +272,41 @@ The editor auto-saves every 30 seconds when running inside WHP. The save status
| 2 | Section | Layout | `layout/Section.tsx` | Full-width with centered inner container, bg color/gradient, vertical padding, inner max-width |
| 3 | ColumnLayout | Layout | `layout/ColumnLayout.tsx` | 1-6 columns, split ratios (50-50, 30-70, 70-30, 33-33-33, 25-25-25-25, etc.), gap control |
| 4 | BackgroundSection | Layout | `layout/BackgroundSection.tsx` | Section with background image, gradient overlay, parallax-ready |
| 5 | HeaderZone | Layout | `layout/HeaderZone.tsx` | Page-level header wrapper zone, used by PageContext |
| 6 | FooterZone | Layout | `layout/FooterZone.tsx` | Page-level footer wrapper zone, used by PageContext |
| 7 | Heading | Basic | `basic/Heading.tsx` | Inline-editable, h1-h6 level, color, font family/size/weight, text align |
| 8 | TextBlock | Basic | `basic/TextBlock.tsx` | Inline-editable paragraph, color, font family/size/weight, text align, line height |
| 9 | ButtonLink | Basic | `basic/ButtonLink.tsx` | Link text/URL/target, 8 color presets (auto text contrast), radius, padding, font size |
| 5 | Heading | Basic | `basic/Heading.tsx` | Inline-editable, h1-h6 level, color, font family/size/weight, text align |
| 6 | TextBlock | Basic | `basic/TextBlock.tsx` | Inline-editable paragraph, color, font family/size/weight, text align, line height |
| 7 | ButtonLink | Basic | `basic/ButtonLink.tsx` | Link text/URL/target, 8 color presets (auto text contrast), radius, padding, font size |
| 8 | Logo | Basic | `basic/Logo.tsx` | Text or image logo, link href, font family/size/weight, image width |
| 9 | Menu | Basic | `basic/Menu.tsx` | Link list with optional CTA styling, horizontal/vertical orientation, alignment, hover colors |
| 10 | Navbar | Basic | `basic/Navbar.tsx` | Text or image logo, page links, external links, CTA buttons, light/dark nav style |
| 11 | Footer | Basic | `basic/Footer.tsx` | Footer with links, copyright, social links |
| 12 | Divider | Basic | `basic/Divider.tsx` | Horizontal rule with color and thickness controls |
| 13 | Spacer | Basic | `basic/Spacer.tsx` | Vertical spacing element with height control |
| 14 | ImageBlock | Media | `media/ImageBlock.tsx` | SVG placeholder, URL input, upload, browse assets, alt text, width/height, object-fit, radius |
| 15 | VideoBlock | Media | `media/VideoBlock.tsx` | YouTube, Vimeo, direct files (.mp4/.webm/.ogg), background mode, autoplay, loop |
| 16 | HeroSimple | Section | `sections/HeroSimple.tsx` | Pre-built hero with heading, subtext, CTA button, gradient/color background |
| 17 | FeaturesGrid | Section | `sections/FeaturesGrid.tsx` | 3-column feature cards with icons, titles, descriptions |
| 18 | CTASection | Section | `sections/CTASection.tsx` | Call-to-action banner with heading, text, button |
| 19 | FormContainer | Form | `forms/FormContainer.tsx` | Form wrapper with action URL and method |
| 20 | InputField | Form | `forms/InputField.tsx` | Text input with label, placeholder, type (text/email/tel/password/number) |
| 21 | TextareaField | Form | `forms/TextareaField.tsx` | Textarea with label and placeholder |
| 22 | FormButton | Form | `forms/FormButton.tsx` | Submit button with color and style controls |
| 14 | Icon | Basic | `basic/Icon.tsx` | Font Awesome icon, size/color, background shape, optional link |
| 15 | ImageBlock | Media | `media/ImageBlock.tsx` | SVG placeholder, URL input, upload, browse assets, alt text, width/height, object-fit, radius |
| 16 | VideoBlock | Media | `media/VideoBlock.tsx` | YouTube, Vimeo, direct files (.mp4/.webm/.ogg), background mode, autoplay, loop |
| 17 | MapEmbed | Media | `media/MapEmbed.tsx` | Embedded map by address, zoom level, height |
| 18 | HeroSimple | Section | `sections/HeroSimple.tsx` | Pre-built hero with heading, subtext, CTA button, gradient/color background |
| 19 | FeaturesGrid | Section | `sections/FeaturesGrid.tsx` | 3-column feature cards with icons, titles, descriptions |
| 20 | CTASection | Section | `sections/CTASection.tsx` | Call-to-action banner with heading, text, button |
| 21 | Countdown | Section | `sections/Countdown.tsx` | Countdown timer to a target date, heading, digit/label colors |
| 22 | Testimonials | Section | `sections/Testimonials.tsx` | Grid or single-layout testimonial cards, star color, card background |
| 23 | FormContainer | Form | `forms/FormContainer.tsx` | Form wrapper with action URL and method |
| 24 | InputField | Form | `forms/InputField.tsx` | Text input with label, placeholder, type (text/email/tel/password/number) |
| 25 | TextareaField | Form | `forms/TextareaField.tsx` | Textarea with label and placeholder |
| 26 | FormButton | Form | `forms/FormButton.tsx` | Submit button with color and style controls |
| 27 | ContactForm | Form | `forms/ContactForm.tsx` | Configurable field list, recipient email, success message or thank-you URL redirect |
| 28 | StarRating | Basic | `basic/StarRating.tsx` | Star rating display, rating/max stars, filled/empty color |
| 29 | SocialLinks | Basic | `basic/SocialLinks.tsx` | Social icon links, size/color/shape, gap, alignment |
| 30 | CallToAction | Section | `sections/CallToAction.tsx` | Heading/description with 1-2 buttons, color/gradient/image background with overlay |
| 31 | Accordion | Section | `sections/Accordion.tsx` | Expand/collapse item list, header/content colors |
| 32 | Tabs | Section | `sections/Tabs.tsx` | Tabbed content panels, active/inactive tab colors |
| 33 | PricingTable | Section | `sections/PricingTable.tsx` | Pricing plan cards, featured plan highlight, bullet list |
| 34 | Gallery | Section | `sections/Gallery.tsx` | Image grid gallery, configurable columns/gap, optional lightbox |
| 35 | ContentSlider | Section | `sections/ContentSlider.tsx` | Auto-playing image/content slider, dots/arrows, configurable interval |
| 36 | NumberCounter | Section | `sections/NumberCounter.tsx` | Animated stat counters, columns, number/label colors |
| 37 | SubscribeForm | Form | `forms/SubscribeForm.tsx` | Inline/stacked email signup form, heading, button color |
| 38 | SearchBar | Basic | `basic/SearchBar.tsx` | Search input with optional button, placeholder text |
| 39 | HtmlBlock | Basic | `basic/HtmlBlock.tsx` | Raw/custom HTML embed block, sanitized on export |
## Site Design Tokens
@@ -351,7 +362,7 @@ Templates are loaded via the Template Modal (opened from TopBar). Loading a temp
## Multi-Page System
Pages are managed through `PageContext`:
- Each page has: `id`, `name`, `slug`, `craftState`, `headCode`
- Each page has: `id`, `name`, `slug`, `craftState` (`headCode` is site-wide only, on `SiteDesignContext`/`SiteDesign`, edited via the TopBar's Head Code modal -- not a per-page field)
- Header and Footer are stored as separate "page" entries with fixed IDs (`__header__`, `__footer__`)
- Page switching serializes the current canvas, stores it, then deserializes the target page
- Header/Footer editing puts the canvas in a distinct mode
@@ -392,21 +403,22 @@ The Assets panel (`AssetsPanel.tsx`) provides:
- Delete asset
- Integration with WHP API for server-side storage
Image and Video components also have inline asset selection (browse button in settings).
Image and video fields elsewhere in the editor (ImageStylePanel, MediaStylePanel, HeroStylePanel, NavStylePanel, BackgroundSectionStylePanel, and array-editor cards like FeaturesEditor) use the shared `AssetPicker` (`src/ui/AssetPicker.tsx`) for upload / browse-uploaded / paste-URL, in a `full` or `compact` variant depending on space.
## Adding New Components
1. Create `src/components/<category>/<ComponentName>.tsx` following the pattern above
2. Add the component to `src/components/resolver.ts`
3. Add a block entry in `src/panels/left/BlocksPanel.tsx` under the appropriate category
4. Implement the `toHtml` static for HTML export
5. Build and test: `npm run dev`, drag the block onto the canvas, verify settings panel, verify HTML export
4. Add or extend a `StylePanel` in `src/panels/right/styles/` and wire it into `GuidedStyles.tsx`'s type dispatch so the new component is editable when selected
5. Implement the `toHtml` static for HTML export
6. Build and test: `npm run dev`, drag the block onto the canvas, verify the style panel, verify HTML export
### Checklist for a new component:
- [ ] Props interface with `style?: CSSProperties`
- [ ] `useNode()` with `connect(drag(ref))` on the root element
- [ ] Settings panel using `useNode()` with `setProp()`
- [ ] `.craft` config with `displayName`, default `props`, `rules`, `related.settings`
- [ ] `StylePanel` entry (new or reused) wired into `GuidedStyles.tsx`
- [ ] `.craft` config with `displayName`, default `props`, `rules`
- [ ] `.toHtml()` static method using `cssPropsToString()`
- [ ] Registered in `resolver.ts`
- [ ] Block added to `BlocksPanel.tsx`
@@ -421,7 +433,7 @@ The editor uses a dark theme defined via CSS custom properties in `src/styles/ed
- **Border:** `#2d2d3a`
- **Font:** Inter
All editor chrome (panels, topbar, settings) is styled via `editor.css`. User content on the canvas uses inline styles exclusively.
All editor chrome (panels, topbar, style panels) is styled via `editor.css`. User content on the canvas uses inline styles exclusively.
## Presets
@@ -446,7 +458,7 @@ Every component has a static `toHtml(props, childrenHtml)` method. The `html-exp
## Testing Approach
- **Manual testing:** Run `npm run dev`, drag components, edit props, verify settings panels
- **Manual testing:** Run `npm run dev`, drag components, edit props, verify style panels
- **Type checking:** `tsc --noEmit` (part of build step)
- **HTML export:** Verify `toHtml()` output matches expected HTML structure
- **Device preview:** Switch between desktop/tablet/mobile and verify responsive behavior
@@ -455,9 +467,9 @@ Every component has a static `toHtml(props, childrenHtml)` method. The `html-exp
## Development Notes
- Path alias `@/` maps to `./src/` (configured in both tsconfig.json and vite.config.ts)
- `GuidedStyles` shows selected component type and delegates to per-component settings panels
- `GuidedStyles` shows the selected component type and dispatches to a shared `StylePanel` in `src/panels/right/styles/` (components no longer carry their own settings UI)
- Text components (Heading, TextBlock) use `contentEditable` for inline editing when selected
- Button/link navigation is prevented in the editor via `e.preventDefault()`
- Image upload integrates with WHP API; in standalone mode falls back to local `blob:` URLs
- Auto-save runs every 30 seconds when connected to WHP API
- The SettingsTabs UI component provides a reusable General/Style/Advanced tab layout for component settings
- `AssetPicker` (`src/ui/AssetPicker.tsx`) is the shared upload/browse/URL control reused across StylePanels for every image and video field
+1 -1
View File
@@ -2,7 +2,7 @@
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<title>Site Builder</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
+217
View File
@@ -8,6 +8,15 @@
"name": "whp-site-builder",
"version": "2.0.0",
"dependencies": {
"@codemirror/autocomplete": "^6.20.3",
"@codemirror/commands": "^6.10.4",
"@codemirror/lang-css": "^6.3.1",
"@codemirror/lang-html": "^6.4.11",
"@codemirror/lang-javascript": "^6.2.5",
"@codemirror/language": "^6.12.4",
"@codemirror/state": "^6.7.1",
"@codemirror/theme-one-dark": "^6.1.3",
"@codemirror/view": "^6.43.6",
"@craftjs/core": "^0.2.10",
"@craftjs/layers": "^0.2.7",
"dompurify": "^3.4.5",
@@ -373,6 +382,133 @@
"specificity": "bin/cli.js"
}
},
"node_modules/@codemirror/autocomplete": {
"version": "6.20.3",
"resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.20.3.tgz",
"integrity": "sha512-tlosUqb+3BbxCxZdu4tKeRghPFC+QM7q4X5YhKV2eCmPG+1r2F3f4AaSz5sCrFqUtX4Jh20VFTKecl16MgiV9g==",
"license": "MIT",
"dependencies": {
"@codemirror/language": "^6.0.0",
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.17.0",
"@lezer/common": "^1.0.0"
}
},
"node_modules/@codemirror/commands": {
"version": "6.10.4",
"resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.10.4.tgz",
"integrity": "sha512-Ryk9y9T0FFVF0cUGhAknveAyUOl/A1qReTFi+qPKtOh2Z9F4AUBz3XOrYD4ZEgZirdugVzHvd/2/Wcwy5OliTg==",
"license": "MIT",
"dependencies": {
"@codemirror/language": "^6.0.0",
"@codemirror/state": "^6.7.0",
"@codemirror/view": "^6.27.0",
"@lezer/common": "^1.1.0"
}
},
"node_modules/@codemirror/lang-css": {
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/@codemirror/lang-css/-/lang-css-6.3.1.tgz",
"integrity": "sha512-kr5fwBGiGtmz6l0LSJIbno9QrifNMUusivHbnA1H6Dmqy4HZFte3UAICix1VuKo0lMPKQr2rqB+0BkKi/S3Ejg==",
"license": "MIT",
"dependencies": {
"@codemirror/autocomplete": "^6.0.0",
"@codemirror/language": "^6.0.0",
"@codemirror/state": "^6.0.0",
"@lezer/common": "^1.0.2",
"@lezer/css": "^1.1.7"
}
},
"node_modules/@codemirror/lang-html": {
"version": "6.4.11",
"resolved": "https://registry.npmjs.org/@codemirror/lang-html/-/lang-html-6.4.11.tgz",
"integrity": "sha512-9NsXp7Nwp891pQchI7gPdTwBuSuT3K65NGTHWHNJ55HjYcHLllr0rbIZNdOzas9ztc1EUVBlHou85FFZS4BNnw==",
"license": "MIT",
"dependencies": {
"@codemirror/autocomplete": "^6.0.0",
"@codemirror/lang-css": "^6.0.0",
"@codemirror/lang-javascript": "^6.0.0",
"@codemirror/language": "^6.4.0",
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.17.0",
"@lezer/common": "^1.0.0",
"@lezer/css": "^1.1.0",
"@lezer/html": "^1.3.12"
}
},
"node_modules/@codemirror/lang-javascript": {
"version": "6.2.5",
"resolved": "https://registry.npmjs.org/@codemirror/lang-javascript/-/lang-javascript-6.2.5.tgz",
"integrity": "sha512-zD4e5mS+50htS7F+TYjBPsiIFGanfVqg4HyUz6WNFikgOPf2BgKlx+TQedI1w6n/IqRBVBbBWmGFdLB/7uxO4A==",
"license": "MIT",
"dependencies": {
"@codemirror/autocomplete": "^6.0.0",
"@codemirror/language": "^6.6.0",
"@codemirror/lint": "^6.0.0",
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.17.0",
"@lezer/common": "^1.0.0",
"@lezer/javascript": "^1.0.0"
}
},
"node_modules/@codemirror/language": {
"version": "6.12.4",
"resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.12.4.tgz",
"integrity": "sha512-1q4PaT+o6PbgpkJt4Q8Fv5XJxTy4FUZ4MWETtyiDw3J0Pyr9E2vqcKL+k9wcvjNTIsauxvE7OfmWj3FRPHQ76A==",
"license": "MIT",
"dependencies": {
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.23.0",
"@lezer/common": "^1.5.0",
"@lezer/highlight": "^1.0.0",
"@lezer/lr": "^1.0.0",
"style-mod": "^4.0.0"
}
},
"node_modules/@codemirror/lint": {
"version": "6.9.7",
"resolved": "https://registry.npmjs.org/@codemirror/lint/-/lint-6.9.7.tgz",
"integrity": "sha512-28/+iWLYxKxsvGYhSYL7zaCZqLz5+FFFDq9tVsvGv9kv8RY4fFAchJ5WX9M3YrrRlTIsECjsXPqeNgnSmNP2dg==",
"license": "MIT",
"dependencies": {
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.42.0",
"crelt": "^1.0.5"
}
},
"node_modules/@codemirror/state": {
"version": "6.7.1",
"resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.7.1.tgz",
"integrity": "sha512-9QzNDgE4EYDnAHfrTlR2lwiPciiOymLtwKK+8yHQzCc7GXhAP9xdEbEJFy2IWB1j9UGUl9BsgMmTo/ImA02T7A==",
"license": "MIT",
"dependencies": {
"@marijn/find-cluster-break": "^1.0.0"
}
},
"node_modules/@codemirror/theme-one-dark": {
"version": "6.1.3",
"resolved": "https://registry.npmjs.org/@codemirror/theme-one-dark/-/theme-one-dark-6.1.3.tgz",
"integrity": "sha512-NzBdIvEJmx6fjeremiGp3t/okrLPYT0d9orIc7AFun8oZcRk58aejkqhv6spnz4MLAevrKNPMQYXEWMg4s+sKA==",
"license": "MIT",
"dependencies": {
"@codemirror/language": "^6.0.0",
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.0.0",
"@lezer/highlight": "^1.0.0"
}
},
"node_modules/@codemirror/view": {
"version": "6.43.6",
"resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.43.6.tgz",
"integrity": "sha512-EVunGSYN1wz1p75WY1s3Xg7t3i8Yol0kGZGizNdX9BUFgMFILYVe8/u6EVpo7Ff5PwbZuILb4QAq7IZoKzIEQA==",
"license": "MIT",
"dependencies": {
"@codemirror/state": "^6.7.0",
"crelt": "^1.0.6",
"style-mod": "^4.1.0",
"w3c-keyname": "^2.2.4"
}
},
"node_modules/@craftjs/core": {
"version": "0.2.12",
"resolved": "https://registry.npmjs.org/@craftjs/core/-/core-0.2.12.tgz",
@@ -1093,6 +1229,69 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@lezer/common": {
"version": "1.5.2",
"resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.5.2.tgz",
"integrity": "sha512-sxQE460fPZyU3sdc8lafxiPwJHBzZRy/udNFynGQky1SePYBdhkBl1kOagA9uT3pxR8K09bOrmTUqA9wb/PjSQ==",
"license": "MIT"
},
"node_modules/@lezer/css": {
"version": "1.3.4",
"resolved": "https://registry.npmjs.org/@lezer/css/-/css-1.3.4.tgz",
"integrity": "sha512-N+tn9tej2hPvyKgHEApMOQfHczDJCwxrRFS3SPn9QjYN+uwHvEDnCgKRrb3mxDYxRS8sKMM8fhC3+lc04Abz5Q==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.2.0",
"@lezer/highlight": "^1.0.0",
"@lezer/lr": "^1.3.0"
}
},
"node_modules/@lezer/highlight": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.3.tgz",
"integrity": "sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.3.0"
}
},
"node_modules/@lezer/html": {
"version": "1.3.13",
"resolved": "https://registry.npmjs.org/@lezer/html/-/html-1.3.13.tgz",
"integrity": "sha512-oI7n6NJml729m7pjm9lvLvmXbdoMoi2f+1pwSDJkl9d68zGr7a9Btz8NdHTGQZtW2DA25ybeuv/SyDb9D5tseg==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.2.0",
"@lezer/highlight": "^1.0.0",
"@lezer/lr": "^1.0.0"
}
},
"node_modules/@lezer/javascript": {
"version": "1.5.4",
"resolved": "https://registry.npmjs.org/@lezer/javascript/-/javascript-1.5.4.tgz",
"integrity": "sha512-vvYx3MhWqeZtGPwDStM2dwgljd5smolYD2lR2UyFcHfxbBQebqx8yjmFmxtJ/E6nN6u1D9srOiVWm3Rb4tmcUA==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.2.0",
"@lezer/highlight": "^1.1.3",
"@lezer/lr": "^1.3.0"
}
},
"node_modules/@lezer/lr": {
"version": "1.4.10",
"resolved": "https://registry.npmjs.org/@lezer/lr/-/lr-1.4.10.tgz",
"integrity": "sha512-rnCpTIBafOx4mRp43xOxDJbFipJm/c0cia/V5TiGlhmMa+wsSdoGmUN3w5Bqrks/09Q/D4tNAmWaT8p6NRi77A==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.0.0"
}
},
"node_modules/@marijn/find-cluster-break": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.3.tgz",
"integrity": "sha512-FY+MKLBoTsLNJF/eLWaOsXGdz6uh3Iu1axjPf6TUq92IYumcTcXWHoS747JARLkcdlJ/Waiaxc5wQfFO8jC6NA==",
"license": "MIT"
},
"node_modules/@playwright/test": {
"version": "1.59.1",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.59.1.tgz",
@@ -1873,6 +2072,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/crelt": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.7.tgz",
"integrity": "sha512-aK6BbWfhf4U/wCcLHKPJl/xa6VkVstRaPywWtMKGwuOLc/wZTyQYuoxgvZnNsBvv7Kg3YTBQYYBCggcviQczuA==",
"license": "MIT"
},
"node_modules/css-color-keywords": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/css-color-keywords/-/css-color-keywords-1.0.0.tgz",
@@ -2686,6 +2891,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/style-mod": {
"version": "4.1.3",
"resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.3.tgz",
"integrity": "sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==",
"license": "MIT"
},
"node_modules/styled-components": {
"version": "6.3.12",
"resolved": "https://registry.npmjs.org/styled-components/-/styled-components-6.3.12.tgz",
@@ -3096,6 +3307,12 @@
}
}
},
"node_modules/w3c-keyname": {
"version": "2.2.8",
"resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz",
"integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==",
"license": "MIT"
},
"node_modules/w3c-xmlserializer": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz",
+9
View File
@@ -14,6 +14,15 @@
"test:unit:watch": "vitest"
},
"dependencies": {
"@codemirror/autocomplete": "^6.20.3",
"@codemirror/commands": "^6.10.4",
"@codemirror/lang-css": "^6.3.1",
"@codemirror/lang-html": "^6.4.11",
"@codemirror/lang-javascript": "^6.2.5",
"@codemirror/language": "^6.12.4",
"@codemirror/state": "^6.7.1",
"@codemirror/theme-one-dark": "^6.1.3",
"@codemirror/view": "^6.43.6",
"@craftjs/core": "^0.2.10",
"@craftjs/layers": "^0.2.7",
"dompurify": "^3.4.5",
+2 -1
View File
@@ -1,6 +1,7 @@
import React from 'react';
import { Editor } from '@craftjs/core';
import { EditorShell } from './editor/EditorShell';
import { RenderNode } from './editor/RenderNode';
import { componentResolver } from './components/resolver';
import { WhpConfig } from './types';
import { EditorConfigProvider } from './state/EditorConfigContext';
@@ -23,7 +24,7 @@ export const App: React.FC<AppProps> = ({ whpConfig }) => {
return (
<EditorConfigProvider config={whpConfig}>
<SiteDesignProvider>
<Editor resolver={componentResolver} enabled={true}>
<Editor resolver={componentResolver} enabled={true} onRender={RenderNode}>
<PageProvider>
<SitesmithProvider>
<EditorShell />
@@ -0,0 +1,120 @@
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>');
});
});
describe('ButtonLink.toHtml hover state (scoped <style> block)', () => {
test('no hover props -- no <style> block, no class added', () => {
const { html } = toHtml({ href: '#', text: 'x' }, '', 'node-1');
expect(html).not.toContain('<style>');
expect(html).not.toContain('class=');
});
test('hoverBg/hoverColor emit a scoped :hover rule scoped to the node id', () => {
const { html } = toHtml({ href: '#', text: 'x', hoverBg: '#111111', hoverColor: '#eeeeee' }, '', 'node-42');
expect(html).toMatch(/<style>\.btn_[a-z0-9]+:hover\{background-color:#111111;color:#eeeeee\}<\/style>/);
expect(html).toMatch(/class="btn_[a-z0-9]+"/);
});
test('two different node ids produce different scope classes (no collision)', () => {
const a = toHtml({ href: '#', text: 'x', hoverBg: '#111111' }, '', 'node-a').html;
const b = toHtml({ href: '#', text: 'x', hoverBg: '#111111' }, '', 'node-b').html;
const scopeOf = (html: string) => html.match(/btn_[a-z0-9]+/)?.[0];
expect(scopeOf(a)).toBeTruthy();
expect(scopeOf(a)).not.toBe(scopeOf(b));
});
test('an XSS breakout attempt in hoverBg cannot close the <style> element', () => {
const malicious = '</style><script>alert(1)</script>';
const { html } = toHtml({ href: '#', text: 'x', hoverBg: malicious }, '', 'node-1');
expect(html).not.toContain('</style><script>');
expect(html).not.toContain('<script>alert(1)</script>');
});
test('a rule-breakout attempt in hoverColor cannot inject a second selector/rule', () => {
const malicious = 'red;}body{background:red';
const { html } = toHtml({ href: '#', text: 'x', hoverColor: malicious }, '', 'node-1');
expect(html).not.toContain('}body{');
expect(html).not.toContain(';}');
// The whole export is still exactly one <style> element -- no new rule
// or element was opened by the malicious value.
expect((html.match(/<style>/g) || []).length).toBe(1);
expect((html.match(/<\/style>/g) || []).length).toBe(1);
});
});
describe('ButtonLink.craft.props exposes target + hover + box-model + animation/visibility', () => {
test('target defaults to _self, hoverBg/hoverColor blank', () => {
const props = (ButtonLink as any).craft.props;
expect(props.target).toBe('_self');
expect(props.hoverBg).toBe('');
expect(props.hoverColor).toBe('');
});
test('animation, animationDelay, hideOnDesktop/Tablet/Mobile are present with blank/false defaults', () => {
const props = (ButtonLink as any).craft.props;
expect(props.animation).toBe('');
expect(props.animationDelay).toBe('0');
expect(props.hideOnDesktop).toBe(false);
expect(props.hideOnTablet).toBe(false);
expect(props.hideOnMobile).toBe(false);
});
test('style carries blank/default box-model keys', () => {
const style = (ButtonLink as any).craft.props.style;
expect(style).toHaveProperty('marginTop');
expect(style.border).toBe('none');
expect(style.boxShadow).toBe('none');
expect(style.opacity).toBe('1');
});
});
+54 -4
View File
@@ -1,13 +1,24 @@
import React, { CSSProperties } from 'react';
import { useNode, UserComponent } from '@craftjs/core';
import { cssPropsToString } from '../../utils/style-helpers';
import { escapeAttr, safeUrl } from '../../utils/escape';
import { escapeHtml, escapeAttr, safeUrl, cssValue, scopeId } from '../../utils/escape';
interface ButtonLinkProps {
text?: string;
href?: string;
target?: '_self' | '_blank';
style?: CSSProperties;
/** Background color applied on `:hover` via a scoped `<style>` block
* (editor preview does not show hover state -- only the published
* export). Blank means "no hover background override". */
hoverBg?: string;
/** Text color applied on `:hover`, same scoped `<style>` block. */
hoverColor?: string;
animation?: string;
animationDelay?: string;
hideOnDesktop?: boolean;
hideOnTablet?: boolean;
hideOnMobile?: boolean;
}
export const ButtonLink: UserComponent<ButtonLinkProps> = ({
@@ -15,6 +26,8 @@ export const ButtonLink: UserComponent<ButtonLinkProps> = ({
href = '#',
target = '_self',
style = {},
hoverBg = '',
hoverColor = '',
}) => {
const {
connectors: { connect, drag },
@@ -23,6 +36,8 @@ export const ButtonLink: UserComponent<ButtonLinkProps> = ({
selected: node.events.selected,
}));
const [hovered, setHovered] = React.useState(false);
return (
<a
ref={(ref: HTMLAnchorElement | null) => { if (ref) connect(drag(ref)); }}
@@ -32,12 +47,16 @@ export const ButtonLink: UserComponent<ButtonLinkProps> = ({
// Prevent navigation inside editor
e.preventDefault();
}}
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
style={{
display: 'inline-block',
textDecoration: 'none',
cursor: 'pointer',
outline: selected ? '2px solid #3b82f6' : 'none',
...style,
...(hovered && hoverBg ? { backgroundColor: hoverBg } : {}),
...(hovered && hoverColor ? { color: hoverColor } : {}),
}}
>
{text}
@@ -53,6 +72,8 @@ ButtonLink.craft = {
text: 'Click Me',
href: '#',
target: '_self',
hoverBg: '',
hoverColor: '',
style: {
backgroundColor: '#3b82f6',
color: '#ffffff',
@@ -61,7 +82,15 @@ ButtonLink.craft = {
fontWeight: '600',
fontSize: '16px',
border: 'none',
marginTop: '', marginRight: '', marginBottom: '', marginLeft: '',
boxShadow: 'none',
opacity: '1',
},
animation: '',
animationDelay: '0',
hideOnDesktop: false,
hideOnTablet: false,
hideOnMobile: false,
},
rules: {
canDrag: () => true,
@@ -72,15 +101,36 @@ ButtonLink.craft = {
/* ---------- HTML export ---------- */
(ButtonLink as any).toHtml = (props: ButtonLinkProps, _childrenHtml: string) => {
(ButtonLink as any).toHtml = (props: ButtonLinkProps, _childrenHtml: string, nodeId?: string) => {
const styleStr = cssPropsToString({
display: 'inline-block',
textDecoration: 'none',
...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"' : '';
// Scoped hover style -- same pattern as Navbar/Menu: a deterministic,
// per-node class (via scopeId) avoids two ButtonLink instances on the
// same page colliding on a shared `.btn-link:hover` rule. hoverBg/
// hoverColor are sanitized through cssValue -- they land inside a
// `<style>` element, the worst-case XSS sink (an unescaped `<`/`>` or
// `{`/`}` could close the rule/element and open a `<script>`).
const hoverBg = cssValue(props.hoverBg);
const hoverColor = cssValue(props.hoverColor);
let hoverCss = '';
let cls = '';
if (hoverBg || hoverColor) {
const scope = scopeId(nodeId, (props.href || '') + (props.text || ''), 'btn');
cls = ` class="${scope}"`;
const decls = [
hoverBg ? `background-color:${hoverBg}` : '',
hoverColor ? `color:${hoverColor}` : '',
].filter(Boolean).join(';');
hoverCss = `<style>.${scope}:hover{${decls}}</style>`;
}
return {
html: `<a href="${escapeAttr(safeUrl(props.href || '#'))}"${targetAttr}${styleStr ? ` style="${styleStr}"` : ''}>${escapedText}</a>`,
html: `${hoverCss}<a href="${escapeAttr(safeUrl(props.href || '#'))}"${targetAttr}${cls}${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,33 @@
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.');
});
});
describe('Footer (F4: box-model + animation + visibility props on craft.props)', () => {
test('craft.props includes animation/visibility defaults so the panel controls always render', () => {
const craftProps = (Footer as any).craft.props;
expect(craftProps).toHaveProperty('animation', 'none');
expect(craftProps).toHaveProperty('animationDelay', '0');
expect(craftProps).toHaveProperty('hideOnDesktop', false);
expect(craftProps).toHaveProperty('hideOnTablet', false);
expect(craftProps).toHaveProperty('hideOnMobile', false);
});
});
+12 -1
View File
@@ -1,10 +1,16 @@
import React, { CSSProperties, useCallback, useRef, useEffect } from 'react';
import { useNode, UserComponent } from '@craftjs/core';
import { cssPropsToString } from '../../utils/style-helpers';
import { escapeHtml } from '../../utils/escape';
interface FooterProps {
text?: string;
style?: CSSProperties;
hideOnDesktop?: boolean;
hideOnTablet?: boolean;
hideOnMobile?: boolean;
animation?: string;
animationDelay?: string;
}
export const Footer: UserComponent<FooterProps> = ({
@@ -91,6 +97,11 @@ Footer.craft = {
fontSize: '14px',
padding: '24px 20px',
},
hideOnDesktop: false,
hideOnTablet: false,
hideOnMobile: false,
animation: 'none',
animationDelay: '0',
},
rules: {
canDrag: () => true,
@@ -107,6 +118,6 @@ Footer.craft = {
textAlign: 'center',
...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>` };
};
@@ -0,0 +1,109 @@
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>');
});
});
describe('Heading.toHtml typography depth (line-height/letter-spacing/transform/style/decoration)', () => {
test('line-height, letter-spacing, text-transform all flow into the style attribute', () => {
const { html } = toHtml({
text: 'x',
level: 'h2',
style: { lineHeight: '1.25', letterSpacing: '0.05em', textTransform: 'uppercase' },
}, '');
expect(html).toContain('line-height:1.25');
expect(html).toContain('letter-spacing:0.05em');
expect(html).toContain('text-transform:uppercase');
});
test('italic + underline toggles emit font-style and text-decoration', () => {
const { html } = toHtml({
text: 'x',
level: 'h2',
style: { fontStyle: 'italic', textDecoration: 'underline' },
}, '');
expect(html).toContain('font-style:italic');
expect(html).toContain('text-decoration:underline');
});
test('a custom font-size (not one of the presets) still flows through', () => {
const { html } = toHtml({ text: 'x', level: 'h2', style: { fontSize: '42px' } }, '');
expect(html).toContain('font-size:42px');
});
});
describe('Heading.craft.props exposes the box-model + animation/visibility rollout', () => {
test('animation, animationDelay, hideOnDesktop/Tablet/Mobile are present with blank/false defaults', () => {
const props = (Heading as any).craft.props;
expect(props.animation).toBe('');
expect(props.animationDelay).toBe('0');
expect(props.hideOnDesktop).toBe(false);
expect(props.hideOnTablet).toBe(false);
expect(props.hideOnMobile).toBe(false);
});
test('style carries blank/default box-model + typography-depth keys', () => {
const style = (Heading as any).craft.props.style;
expect(style).toHaveProperty('marginTop');
expect(style).toHaveProperty('paddingTop');
expect(style).toHaveProperty('lineHeight');
expect(style).toHaveProperty('letterSpacing');
expect(style).toHaveProperty('textTransform');
expect(style.border).toBe('none');
expect(style.boxShadow).toBe('none');
expect(style.opacity).toBe('1');
});
});
+30 -3
View File
@@ -1,9 +1,20 @@
import React, { CSSProperties, useCallback, useRef, useEffect } from 'react';
import { useNode, UserComponent } from '@craftjs/core';
import { cssPropsToString } from '../../utils/style-helpers';
import { escapeHtml } from '../../utils/escape';
type HeadingLevel = 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6';
// `level` is settable via the AI `update_props` path and from deserialized
// saved state -- neither type-checked at runtime -- and is interpolated
// directly into the tag position (`React.createElement(level, ...)` /
// `<${tag}` in `toHtml`). A malicious value like `h2><img src=x
// onerror=alert(1)` (or a non-h1-6 string) must never reach that position
// unchecked. Anything not in this allowlist clamps to `'h2'`.
const ALLOWED_HEADING_LEVELS = ['h1', 'h2', 'h3', 'h4', 'h5', 'h6'] as const;
const sanitizeHeadingLevel = (level: unknown): HeadingLevel =>
(ALLOWED_HEADING_LEVELS as readonly unknown[]).includes(level) ? (level as HeadingLevel) : 'h2';
interface HeadingProps {
text?: string;
level?: HeadingLevel;
@@ -30,6 +41,7 @@ export const Heading: UserComponent<HeadingProps> = ({
selected: node.events.selected,
}));
const safeLevel = sanitizeHeadingLevel(level);
const elRef = useRef<HTMLElement | null>(null);
const editedTextRef = useRef<string | null>(null);
@@ -59,7 +71,7 @@ export const Heading: UserComponent<HeadingProps> = ({
}
}, [text, selected]);
return React.createElement(level, {
return React.createElement(safeLevel, {
ref: (ref: HTMLElement | null): void => {
elRef.current = ref;
if (ref) connect(drag(ref));
@@ -88,7 +100,22 @@ Heading.craft = {
fontFamily: 'Inter, sans-serif',
color: '#1f2937',
marginBottom: '16px',
lineHeight: '',
letterSpacing: '',
textTransform: '' as CSSProperties['textTransform'],
fontStyle: '' as CSSProperties['fontStyle'],
textDecoration: '',
marginTop: '', marginRight: '', marginLeft: '',
paddingTop: '', paddingRight: '', paddingBottom: '', paddingLeft: '',
border: 'none',
boxShadow: 'none',
opacity: '1',
},
animation: '',
animationDelay: '0',
hideOnDesktop: false,
hideOnTablet: false,
hideOnMobile: false,
},
rules: {
canDrag: () => true,
@@ -98,8 +125,8 @@ Heading.craft = {
};
(Heading as any).toHtml = (props: HeadingProps, _childrenHtml: string) => {
const tag = props.level || 'h2';
const safeText = (props.text || '').replace(/</g, '&lt;').replace(/>/g, '&gt;');
const tag = sanitizeHeadingLevel(props.level);
const safeText = escapeHtml(props.text || '');
const styleStr = cssPropsToString(props.style);
return { html: `<${tag}${styleStr ? ` style="${styleStr}"` : ''}>${safeText}</${tag}>` };
};
@@ -0,0 +1,51 @@
import { describe, test, expect, vi } from 'vitest';
import React from 'react';
import { createRoot, Root } from 'react-dom/client';
import { act } from 'react-dom/test-utils';
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 { HtmlBlock } from './HtmlBlock';
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);
});
}
describe('HtmlBlock render ignores the style prop (matches toHtml)', () => {
test('a stored backgroundColor/color/padding is NOT applied to the wrapper', () => {
render(
React.createElement(HtmlBlock as any, {
code: '<p>hello</p>',
style: { backgroundColor: 'rgb(255, 0, 0)', color: 'rgb(0, 0, 255)', padding: '40px' },
}),
);
const wrapper = container.firstElementChild as HTMLElement;
expect(wrapper.style.backgroundColor).toBe('');
expect(wrapper.style.color).toBe('');
expect(wrapper.style.padding).toBe('');
});
test('the editor affordances survive: minHeight is kept, content still renders', () => {
render(React.createElement(HtmlBlock as any, { code: '<p>hello</p>', style: {} }));
const wrapper = container.firstElementChild as HTMLElement;
expect(wrapper.style.minHeight).toBe('40px');
expect(wrapper.innerHTML).toContain('hello');
});
});
@@ -0,0 +1,332 @@
import { describe, test, expect } from 'vitest';
import { purifyHtml } from './HtmlBlock';
// Vite/Vitest `?raw` import -- ships the exact bytes of the file as a
// string, declared by node_modules/vite/client.d.ts. This is a checked-in
// copy of the reference acceptance fixture used for Task 24 (widening the
// Custom HTML block's sanitiser allow-list); keep it byte-identical to the
// external fixture used to drive this task so these tests cannot silently
// drift from the thing they are supposed to be testing against.
import fixtureHtml from './__fixtures__/html-block-test-body.html?raw';
/**
* Task 24: the site owner tested a broad HTML fixture against the shipped
* sanitiser config and found 38% of it silently deleted -- merged table
* cells collapsing (colspan/rowspan/scope stripped), <dl>/<sub>/<details>/
* inline <svg>/<video>/<audio> dropped wholesale, lang/dir/role stripped
* (breaking RTL rendering), <ol start/reversed> flattened. The fix widens
* ALLOWED_TAGS/ALLOWED_ATTR in HtmlBlock.tsx. These tests run the *actual*
* reference fixture through the *actual* purifyHtml() and assert the
* previously-broken constructs now survive with their meaningful
* attributes intact, while re-confirming (with attack payloads spliced
* into the newly-widened surface -- forms, media, inline svg) that the
* four non-negotiable security properties still hold.
*/
describe('purifyHtml -- Task 24 fixture regression (formerly-dropped constructs survive)', () => {
const out = purifyHtml(fixtureHtml);
test('table merged cells keep colspan/rowspan/scope', () => {
expect(out).toContain('<td colspan="2">');
expect(out).toContain('<td rowspan="2">');
expect(out).toContain('<th scope="col">');
expect(out).toContain('<th scope="row">');
});
test('definition list keeps its dl/dt/dd structure (was flattened to "TermDef")', () => {
expect(out).toMatch(/<dl>[\s\S]*<dt>Term one<\/dt>[\s\S]*<dd>Definition of the first term\.<\/dd>[\s\S]*<\/dl>/);
});
test('menu list survives with nested buttons', () => {
expect(out).toMatch(/<menu>[\s\S]*<button type="button">Copy<\/button>[\s\S]*<\/menu>/);
});
test('sub/sup survive (was flattened to "H2O")', () => {
expect(out).toContain('H<sub>2</sub>O');
expect(out).toContain('x<sup>2</sup>');
});
test('details/summary survive with the open attribute (was flattened)', () => {
expect(out).toContain('<summary>Collapsed disclosure</summary>');
expect(out).toContain('<details open="">');
});
test('hgroup survives', () => {
expect(out).toMatch(/<hgroup>[\s\S]*<h2>Grouped heading<\/h2>/);
});
test('inline svg survives with its shape children and role/aria-label (was deleted entirely)', () => {
expect(out).toMatch(/<svg[^>]*role="img"[^>]*aria-label="Two shapes"[^>]*>/);
expect(out).toMatch(/<rect[^>]*fill="none"[^>]*stroke="currentColor"[^>]*>/);
expect(out).toMatch(/<circle[^>]*cx="135"[^>]*cy="45"[^>]*r="40"[^>]*>/);
expect(out).toMatch(/<text[^>]*text-anchor="middle"[^>]*>svg<\/text>/);
});
test('picture/source with media+srcset survive', () => {
expect(out).toContain('<source media="(min-width: 800px)" srcset="wide.png">');
expect(out).toContain('<source media="(min-width: 400px)" srcset="medium.png">');
});
test('video/audio survive with source/track children (was deleted entirely)', () => {
expect(out).toMatch(/<video[^>]*controls=""[^>]*poster="poster\.jpg"[^>]*>/);
expect(out).toContain('<source src="clip.webm" type="video/webm">');
expect(out).toContain('<track kind="captions" src="captions.vtt" srclang="en" label="English">');
expect(out).toMatch(/<audio[^>]*controls=""[^>]*>/);
});
test('canvas survives with its fallback text', () => {
expect(out).toContain('<canvas width="200" height="60">Canvas fallback text</canvas>');
});
test('mark/small/del/ins survive as distinct elements (was flattened to "msdi")', () => {
expect(out).toContain('<mark>mark</mark>');
expect(out).toContain('<small>small</small>');
expect(out).toContain('<del>del</del>');
expect(out).toContain('<ins>ins</ins>');
});
test('lang/dir preserved for RTL text (was stripped, breaking Arabic/Hebrew rendering)', () => {
expect(out).toContain('lang="ar" dir="rtl"');
expect(out).toContain('lang="he" dir="rtl"');
});
test('role attribute preserved alongside aria-* (role was stripped)', () => {
expect(out).toMatch(/<nav aria-label="Primary">/);
expect(out).toMatch(/role="img"/);
});
test('ol start/reversed preserved (was flattened to plain <ol>)', () => {
expect(out).toContain('<ol start="5" reversed="">');
});
test('text semantics survive: abbr/cite/q/time/data/kbd/samp/var/dfn/address/bdi/bdo/ruby', () => {
expect(out).toContain('<abbr title="HyperText Markup Language">HTML</abbr>');
expect(out).toContain('<kbd>Ctrl</kbd>');
expect(out).toContain('<samp>output text</samp>');
expect(out).toContain('<var>variable</var>');
expect(out).toContain('<dfn>definition term</dfn>');
expect(out).toContain('<address>');
expect(out).toContain('<bdi>');
expect(out).toContain('<bdo dir="rtl">');
expect(out).toContain('<ruby>');
expect(out).toContain('<rt>kan</rt>');
expect(out).toContain('<time datetime="2026-08-09">');
expect(out).toContain('<data value="42">');
});
test('wbr survives (word-break opportunity)', () => {
expect(out).toContain('super<wbr>cali<wbr>fragilistic');
});
test('hidden attribute survives', () => {
expect(out).toContain('<p hidden="">');
});
test('forms survive end-to-end: fieldset/legend/label/select/optgroup/option/textarea/datalist/output/progress/meter', () => {
expect(out).toContain('<form action="#" method="get">');
expect(out).toContain('<fieldset>');
expect(out).toContain('<legend>Text inputs</legend>');
expect(out).toContain('<label for="f-text">Text</label>');
expect(out).toContain('<input id="f-text" name="text" type="text" placeholder="Placeholder" value="Prefilled">');
expect(out).toContain('<input id="f-email" type="email" required="">');
expect(out).toContain('<input id="f-num" type="number" min="0" max="100" step="5" value="25">');
expect(out).toContain('<input id="f-ro" type="text" value="read only" readonly="">');
expect(out).toContain('<input id="f-dis" type="text" value="disabled" disabled="">');
expect(out).toContain('<input type="checkbox" name="c" value="1" checked="">');
expect(out).toContain('<select id="f-select" name="select">');
expect(out).toContain('<optgroup label="Group one">');
expect(out).toContain('<option value="1" selected="">One</option>');
expect(out).toContain('<select id="f-multi" multiple="" size="4">');
expect(out).toContain('<datalist id="suggestions">');
expect(out).toContain('<textarea id="f-area" rows="4" cols="40">');
expect(out).toContain('<output name="result" for="f-num f-range">');
expect(out).toContain('<progress id="f-prog" value="0.6">');
expect(out).toContain('<meter id="f-meter" min="0" max="100" low="30" high="80" optimum="90" value="72">');
expect(out).toContain('<button type="submit">Submit</button>');
});
test('bug fix: <select size> and <meter low/high/optimum> survive (both tags were already allowed, only these four attrs were missing)', () => {
expect(out).toContain('<select id="f-multi" multiple="" size="4">');
expect(out).toContain('low="30" high="80" optimum="90"');
});
test('fixture byte survival crosses 90% (was 61.6% -- 9739/15815 -- before Task 24)', () => {
expect(out.length).toBeGreaterThan(fixtureHtml.length * 0.9);
});
});
describe('purifyHtml -- Task 24: things in the fixture that must still be dropped', () => {
const out = purifyHtml(fixtureHtml);
test('style tag never survives', () => {
expect(out).not.toMatch(/<style[\s>]/i);
});
test('script tag never survives', () => {
expect(out).not.toMatch(/<script[\s>]/i);
});
test('dialog/template stay excluded (not in the widened allow-list)', () => {
expect(out).not.toContain('<dialog');
expect(out).not.toContain('<template');
});
test('no on* handler survives anywhere in the widened output, including inside the dialog fallback content', () => {
expect(out).not.toMatch(/\son[a-z]+\s*=/i);
// The fixture's dialog/close buttons carry onclick specifically to
// prove this; their text content should still come through once the
// handler is stripped and (for dialog) the wrapping tag is dropped.
expect(out).toContain('Open dialog');
});
});
describe('purifyHtml -- Task 24: security properties on newly-allowed elements', () => {
test('script inside a newly-allowed <form> still never survives', () => {
const out = purifyHtml('<form><script>alert(1)</script></form>');
expect(out).not.toContain('<script');
});
test('on* handlers never survive on newly-allowed form controls', () => {
const out = purifyHtml('<input onfocus="alert(1)" value="x">');
expect(out).not.toMatch(/onfocus/i);
const out2 = purifyHtml('<select onchange="alert(1)"><option>x</option></select>');
expect(out2).not.toMatch(/onchange/i);
});
test('javascript: blocked in <form action>', () => {
const out = purifyHtml('<form action="javascript:alert(1)"><button type="submit">go</button></form>');
expect(out).not.toContain('javascript:');
});
test('formaction is not in the allow-list at all -- dropped regardless of value', () => {
const out = purifyHtml('<button formaction="javascript:alert(1)">go</button>');
expect(out).not.toContain('formaction');
expect(out).not.toContain('javascript:');
});
test('javascript: blocked on svg <a xlink:href> (xlink:href is not allow-listed at all)', () => {
const out = purifyHtml('<svg><a xlink:href="javascript:alert(1)">click</a></svg>');
expect(out).not.toContain('javascript:');
expect(out).not.toContain('xlink:href');
});
test('javascript: blocked in newly-allowed media URL attributes (poster, source src)', () => {
const out = purifyHtml('<video poster="javascript:alert(1)"><source src="javascript:alert(2)"></video>');
expect(out).not.toContain('javascript:');
});
test('javascript: still blocked in plain href alongside the widened surface', () => {
const out = purifyHtml('<a href="javascript:alert(1)"><svg><text>x</text></svg></a>');
expect(out).not.toContain('javascript:');
});
test('iframe still gets the forced restrictive sandbox + referrerpolicy alongside the widened surface', () => {
const out = purifyHtml('<form><input></form><iframe src="https://example.com/"></iframe>');
expect(out).toMatch(/<iframe[^>]*\bsandbox="[^"]+"/);
const sandbox = out.match(/sandbox="([^"]*)"/)![1];
expect(sandbox).not.toMatch(/allow-top-navigation/);
expect(out).toContain('referrerpolicy="no-referrer"');
});
test('on* on an iframe is still stripped even though iframe now sits among many more allowed siblings', () => {
const out = purifyHtml('<iframe src="https://example.com/" onload="alert(1)"></iframe>');
expect(out).not.toMatch(/onload/i);
});
test('Task 25: style tag nested inside the newly-allowed inline svg now survives, scoped', () => {
// Was "style tag stays blocked" pre-Task-25 -- <style> is now a
// deliberate escape hatch (see HtmlBlock.tsx's ALLOWED_TAGS/Task 25
// comment), including copies nested inside inline SVG:
// querySelectorAll('style') in scopeStyleBlocks() doesn't care about
// namespace/nesting depth, because CSS itself doesn't respect SVG
// subtree boundaries -- an unscoped <style> inside <svg> would still
// apply page-wide, so it needs the same scoping as a top-level one.
const out = purifyHtml('<svg><style>svg{color:red}</style><rect width="1" height="1"></rect></svg>');
expect(out).toMatch(/<style/i);
expect(out).not.toContain('<style>svg{color:red}</style>'); // rewritten, not verbatim
expect(out).toMatch(/\.whp-html-\w+ svg\{color:red\}/);
expect(out).toContain('<rect');
});
test('contenteditable does not smuggle an event handler in alongside it', () => {
const out = purifyHtml('<div contenteditable="true" onblur="alert(1)">x</div>');
expect(out).not.toMatch(/onblur/i);
expect(out).toContain('contenteditable="true"');
});
test('dialog stays excluded even with an attack payload; its inert children still render', () => {
const out = purifyHtml('<dialog onclick="alert(1)"><p>hi</p></dialog>');
expect(out).not.toContain('<dialog');
expect(out).not.toMatch(/onclick/i);
expect(out).toContain('<p>hi</p>');
});
test('javascript: blocked via data: smuggling on newly-allowed poster/cite/action attributes', () => {
// data: is only allow-listed for data:image/*;base64, -- confirm the
// regex is not accidentally satisfied by a text/html or bare data:
// payload on any of the newly URI-checked attributes.
const out = purifyHtml(
'<video poster="data:text/html,<script>alert(1)</script>"></video>' +
'<blockquote cite="data:text/html,x">q</blockquote>' +
'<form action="data:text/html,x"></form>',
);
expect(out).not.toContain('data:text/html');
});
test('review fix: data:image/*;base64, URIs now actually survive on poster/cite/href (dead-code regex bug)', () => {
// ALLOWED_URI_REGEXP used to put the data:image arm inside the group
// that gets a trailing `:` appended to every alternative, requiring a
// second colon after the one already in "base64," -- which no real
// data URI has, so the clause could never match anything. Confirm the
// fixed regex actually allows a real base64 image data URI through on
// ordinary URI-checked attributes (not just the DATA_URI_TAGS-covered
// src ones tested below).
const b64 = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=';
const out = purifyHtml(
`<video poster="data:image/png;base64,${b64}"></video>` +
`<blockquote cite="data:image/png;base64,${b64}">q</blockquote>` +
`<a href="data:image/png;base64,${b64}">img</a>`,
);
expect(out).toContain(`poster="data:image/png;base64,${b64}"`);
expect(out).toContain(`cite="data:image/png;base64,${b64}"`);
expect(out).toContain(`href="data:image/png;base64,${b64}"`);
});
test('documented reality: data: on img/video/audio/source src is mimetype-blind (DOMPurify DATA_URI_TAGS bypasses ALLOWED_URI_REGEXP)', () => {
// This is NOT gated by ALLOWED_URI_REGEXP at all -- DOMPurify has its
// own internal DATA_URI_TAGS allow-list (img, video, audio, source,
// image, track) that accepts ANY data: URI on the `src` attribute of
// those tags regardless of declared mimetype, before our regex is ever
// consulted. Acceptable because none of those tags execute their src
// as a document/script context in mainstream browsers -- the sink
// doesn't execute. Pinned here so a future DOMPurify version change to
// DATA_URI_TAGS shows up as a failing test, not a surprise in
// production. <iframe> -- the one tag where this WOULD be dangerous --
// is correctly not in DOMPurify's DATA_URI_TAGS list, so its src still
// goes through the normal ALLOWED_URI_REGEXP check and gets stripped.
const b64 = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=';
const imgOut = purifyHtml(`<img src="data:text/html;base64,${b64}">`);
expect(imgOut).toContain(`src="data:text/html;base64,${b64}"`);
const iframeOut = purifyHtml(`<iframe src="data:text/html;base64,${b64}"></iframe>`);
expect(iframeOut).not.toContain('data:');
});
});
describe('purifyHtml -- bug fix: size/low/high/optimum were stripped despite select/meter being allowed tags', () => {
test('<select size> survives with its value intact (multi-select row count)', () => {
const out = purifyHtml('<select size="4"><option>a</option></select>');
expect(out).toContain('size="4"');
});
test('<input size> survives with its value intact', () => {
const out = purifyHtml('<input type="text" size="10">');
expect(out).toContain('size="10"');
});
test('<meter low/high/optimum> survive with their values intact (threshold-based gauge colouring)', () => {
const out = purifyHtml('<meter low="1" high="9" optimum="5" value="4" min="0" max="10"></meter>');
expect(out).toContain('low="1"');
expect(out).toContain('high="9"');
expect(out).toContain('optimum="5"');
});
});
+379 -2
View File
@@ -1,5 +1,17 @@
import { describe, test, expect } from 'vitest';
import { purifyHtml } from './HtmlBlock';
import { stableHash } from '../../utils/escape';
import fixtureHtml from './__fixtures__/html-block-test-body.html?raw';
// Ground truth "before" output: purifyHtml(fixtureHtml) computed with the
// EXACT HtmlBlock.tsx code as it stood at commit 6a9b227 (the commit
// immediately before Task 25 -- `git show
// 6a9b227:craft/src/components/basic/HtmlBlock.tsx`), run against the real
// dompurify+jsdom, not guessed at or re-derived from reading the code. See
// the "byte-diff against 6a9b227" describe block below -- this is the
// literal regression check the Task 25 review asked for, after the first
// round of `.not.toContain(...)`-style tests passed while FORCE_BODY was
// silently changing output for a comment-led, style-free fixture.
import preTask25FixtureOutput from './__fixtures__/html-block-test-body.pre-task25-output.html?raw';
describe('purifyHtml', () => {
test('strips script tags', () => {
@@ -17,7 +29,372 @@ describe('purifyHtml', () => {
const out = purifyHtml('<iframe src="https://www.youtube.com/embed/abc" allowfullscreen></iframe>');
expect(out).toContain('youtube.com/embed/abc');
});
test('strips form/input', () => {
expect(purifyHtml('<form><input name="x"></form>')).not.toContain('<form');
test('allows form/input (Task 24: forms are a deliberate escape-hatch addition) but still strips on*/script inside them', () => {
const out = purifyHtml('<form><input name="x" onfocus="bad()"><script>alert(1)</script></form>');
expect(out).toContain('<form');
expect(out).toContain('<input name="x">');
expect(out).not.toContain('onfocus');
expect(out).not.toContain('<script');
});
});
describe('purifyHtml markup path (C1 review finding)', () => {
test('a style attribute survives sanitization (colour picker output must not be silently dropped)', () => {
const out = purifyHtml('<p style="color: #ff0000">red text</p>');
expect(out).toBe('<p style="color: #ff0000">red text</p>');
});
test('an id attribute survives sanitization (anchor targets)', () => {
const out = purifyHtml('<a href="#section" id="section">link</a>');
expect(out).toContain('id="section"');
});
test('a pasted table survives sanitization', () => {
const input = '<table><thead><tr><th>Head</th></tr></thead><tbody><tr><td>Cell</td></tr></tbody></table>';
expect(purifyHtml(input)).toBe(input);
});
test('script tags still do not survive alongside a style attribute', () => {
const out = purifyHtml('<p style="color:#ff0000">ok</p><script>alert(1)</script>');
expect(out).not.toContain('<script');
expect(out).toContain('style="color:#ff0000"');
});
test('on* handlers still do not survive on an element that also carries style', () => {
const out = purifyHtml('<p style="color:#ff0000" onclick="bad()">x</p>');
expect(out).not.toContain('onclick');
expect(out).toContain('style="color:#ff0000"');
});
test('javascript: URLs still do not survive on an element that also carries style', () => {
const out = purifyHtml('<a style="color:#ff0000" href="javascript:void(0)">x</a>');
expect(out).not.toContain('javascript:');
expect(out).toContain('style="color:#ff0000"');
});
});
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>');
});
});
describe('purifyHtml -- Task 25: block-scoped <style> support', () => {
test('a block with no <style> at all is untouched: no wrapper div added', () => {
const out = purifyHtml('<p>hello</p>');
expect(out).toBe('<p>hello</p>');
expect(out).not.toContain('<div');
});
test('blocks WITHOUT <style> are byte-identical to pre-Task-25 output (no wrapper regression)', () => {
// Same representative inputs the Task 24 suite already pins to an exact
// string -- re-asserted here under the Task 25 name so a future change
// that starts wrapping every block (not just style-bearing ones) fails
// loudly and obviously, not just as a Task 24 side-effect.
expect(purifyHtml('<p style="color: #ff0000">red text</p>')).toBe('<p style="color: #ff0000">red text</p>');
const table = '<table><thead><tr><th>Head</th></tr></thead><tbody><tr><td>Cell</td></tr></tbody></table>';
expect(purifyHtml(table)).toBe(table);
expect(purifyHtml('<a href="/x">x</a>')).toBe('<a href="/x">x</a>');
});
test('the full Task 24 fixture (no <style> in it) produces no wrapper and is unaffected', () => {
// The fixture is the broadest real-world stand-in this repo has for
// "a customer's actual pasted block". It contains no <style>, so this
// is the closest thing to a real before/after diff over a large,
// realistic input: the only lever Task 25 pulled (allowing <style> +
// FORCE_BODY) must produce PRECISELY the same output as before for
// content that never touches that lever.
const out = purifyHtml(fixtureHtml);
expect(out).not.toContain('<div class="whp-html-');
expect(out).not.toMatch(/<style[\s>]/i); // still no bare <style> in this fixture
});
test('an empty <style></style> (no CSS content) does not trigger a wrapper', () => {
const out = purifyHtml('<p>hi</p><style></style>');
expect(out).not.toContain('<div class="whp-html-');
});
test('a whitespace-only <style> does not trigger a wrapper', () => {
const out = purifyHtml('<p>hi</p><style> \n </style>');
expect(out).not.toContain('<div class="whp-html-');
});
test('a block WITH real <style> content gets wrapped in a scope-class div', () => {
const out = purifyHtml('<style>h1 { color: red; }</style><h1>Hi</h1>');
expect(out).toMatch(/^<div class="whp-html-[0-9a-z]+">/);
expect(out).toContain('<h1>Hi</h1>');
});
test('the style content is rewritten to only match inside the wrapper (the actual leak-prevention property)', () => {
const out = purifyHtml('<style>h1 { color: red; }</style><h1>Hi</h1>');
const scopeClass = out.match(/class="(whp-html-[0-9a-z]+)"/)![1];
expect(out).toContain(`.${scopeClass} h1 { color: red; }`);
// The bare, unscoped rule must not appear anywhere in the output --
// that's exactly the leak this feature exists to close.
expect(out).not.toContain('<style>h1 { color: red; }</style>');
});
test('scope class is deterministic: the SAME code produces the SAME class across repeated calls', () => {
const code = '<style>p { color: blue; }</style><p>x</p>';
const out1 = purifyHtml(code);
const out2 = purifyHtml(code);
expect(out1).toBe(out2);
const class1 = out1.match(/class="(whp-html-[0-9a-z]+)"/)![1];
const class2 = out2.match(/class="(whp-html-[0-9a-z]+)"/)![1];
expect(class1).toBe(class2);
});
test('pinned scope class for a known input -- guards against silent hash-function drift', () => {
// If this ever needs to change, it means the hash function itself
// changed -- which would silently churn every stored site's HTML on
// next save and desync already-published pages from a fresh Preview.
// That should be a loud, deliberate decision, not a side-effect of an
// unrelated refactor -- hence pinning the literal output here.
const code = '<style>h1{color:red}</style>';
expect(stableHash(code)).toBe('5fwbyn');
const out = purifyHtml(code);
expect(out).toContain('class="whp-html-5fwbyn"');
});
test('scope class is a pure function of `code` -- does not depend on Craft node id or call order', () => {
// purifyHtml's signature only ever takes the code string -- there is no
// node id parameter it could even reach for. This test documents that
// invariant so a future refactor threading a node id through here (as
// html-export.ts's renderNode already does for OTHER components, see
// its `scopeId` comment) doesn't silently get wired into this path too.
const codeA = '<style>h1 { color: red; }</style><h1>same content</h1>';
const codeB = '<style>h1 { color: red; }</style><h1>same content</h1>';
expect(codeA).toBe(codeB); // sanity: truly identical strings
const outA = purifyHtml(codeA);
const outB = purifyHtml(codeB);
expect(outA).toBe(outB);
});
test('FORCE_BODY regression: a block whose ENTIRE code is a leading <style> (nothing before it) still survives', () => {
// Without FORCE_BODY, DOMPurify parses `code` via DOMParser as a mini
// HTML document and serializes only <body>. Per the HTML5 parsing
// algorithm, a <style> tag with nothing before it is implicitly placed
// in the parser's <head>, which DOMPurify's body-only serialization
// never looks at -- the whole block would silently vanish. Confirmed
// empirically against dompurify+jsdom directly before this fix existed.
const out = purifyHtml('<style>h1{color:red}</style>');
expect(out).toContain('<style>');
expect(out).toContain('color:red');
});
test('FORCE_BODY regression: leading <style> immediately followed by markup, both survive', () => {
const out = purifyHtml('<style>h1{color:red}</style><h1>Hi</h1>');
expect(out).toContain('<h1>Hi</h1>');
expect(out).toMatch(/<style>[\s\S]*color:\s*red/);
});
test(':root / html / body inside a block map to the block wrapper itself (end-to-end through purifyHtml)', () => {
const out = purifyHtml('<style>:root { --brand: red; } body { margin: 0; }</style><p>x</p>');
const scopeClass = out.match(/class="(whp-html-[0-9a-z]+)"/)![1];
expect(out).toContain(`.${scopeClass} { --brand: red; }`);
expect(out).toContain(`.${scopeClass} { margin: 0; }`);
});
test('@import is stripped end-to-end (network-fetch/exfiltration channel)', () => {
const out = purifyHtml('<style>@import url("https://evil.example/x.css"); h1{color:red}</style><h1>x</h1>');
expect(out).not.toContain('@import');
expect(out).not.toContain('evil.example');
expect(out).toContain('color:red');
});
test('@keyframes body is not scoped (animation would otherwise break) -- end-to-end through purifyHtml', () => {
const out = purifyHtml(
'<style>@keyframes spin { from { opacity: 0; } to { opacity: 1; } }</style><h1>x</h1>',
);
expect(out).toContain('@keyframes spin');
expect(out).toMatch(/@keyframes spin\s*\{\s*from\s*\{\s*opacity:\s*0;?\s*\}\s*to\s*\{\s*opacity:\s*1;?\s*\}\s*\}/);
});
test('multiple <style> blocks in one Custom HTML block are each scoped under the SAME class', () => {
const out = purifyHtml('<style>h1{color:red}</style><h1>A</h1><style>p{color:blue}</style><p>B</p>');
const classes = [...out.matchAll(/class="(whp-html-[0-9a-z]+)"/g)].map((m) => m[1]);
expect(classes.length).toBeGreaterThanOrEqual(1);
expect(new Set(classes).size).toBe(1); // same block -> same scope class everywhere
});
});
describe('purifyHtml -- Task 25: security properties of the newly-allowed <style>', () => {
test('</style> inside a CSS comment cannot break out into executable markup', () => {
const out = purifyHtml(
'<style>/* </style><script>alert(1)</script> */ h1{color:red}</style><p>hi</p>',
);
expect(out).not.toContain('<script');
expect(out).not.toMatch(/on[a-z]+\s*=/i);
});
test('</style> inside a CSS string cannot break out into executable markup', () => {
const out = purifyHtml(
'<style>h1::before{content:"</style><script>alert(1)</script>"}</style><p>hi</p>',
);
expect(out).not.toContain('<script');
});
test('script/on*/javascript: are still stripped from markup sitting alongside a styled block', () => {
const out = purifyHtml(
'<style>h1{color:red}</style><p onclick="alert(1)">x</p><script>alert(2)</script><a href="javascript:alert(3)">y</a>',
);
expect(out).not.toMatch(/onclick/i);
expect(out).not.toContain('<script');
expect(out).not.toContain('javascript:');
});
test('iframe sandboxing still applies alongside a styled block', () => {
const out = purifyHtml('<style>h1{color:red}</style><iframe src="https://example.com/"></iframe>');
expect(out).toMatch(/<iframe[^>]*\bsandbox="[^"]+"/);
});
test(
'documented reality: DOMPurify does not sanitize CSS declaration values -- ' +
'expression()/behavior/-moz-binding pass through untouched (dead in modern browsers, ' +
'not exploitable there, but not filtered by this pipeline either)',
() => {
const out = purifyHtml(
'<style>div{width:expression(alert(1));behavior:url(evil.htc);-moz-binding:url(evil.xml#x)}</style><div>x</div>',
);
expect(out).toContain('expression(alert(1))');
expect(out).toContain('behavior:url(evil.htc)');
expect(out).toContain('-moz-binding:url(evil.xml#x)');
},
);
test('documented reality: url() to a remote host survives (legitimate for background-image, but a known CSS-exfiltration channel already accepted elsewhere in this config)', () => {
const out = purifyHtml('<style>div{background:url(https://tracker.example/pixel.png)}</style><div>x</div>');
expect(out).toContain('tracker.example');
});
});
describe('purifyHtml -- review finding: raw byte-diff against HtmlBlock.tsx@6a9b227 (the commit before Task 25)', () => {
// Round 1 of this task's tests used `.not.toContain(...)`/`.toContain(...)`
// assertions for the "no <style> => unchanged" guarantee. Those all
// passed while FORCE_BODY: true (applied unconditionally at the time)
// was silently changing the ACTUAL bytes for any style-free block that
// starts with a multi-line HTML comment -- including this repo's own
// fixture, which is exactly that shape. `.not.toContain` can't catch an
// extra leading newline; only a raw diff against the real old output
// can. These tests do that: `preTask25FixtureOutput` is
// `purifyHtml(fixtureHtml)` computed with the UNMODIFIED HtmlBlock.tsx
// source at 6a9b227 (via `git show 6a9b227:...`), run against the real
// dompurify+jsdom, not re-derived from reading the code -- see that
// fixture file's own header comment.
test('the fixture (comment-led, no <style>) is byte-identical to the pre-Task-25 output', () => {
expect(fixtureHtml.startsWith('<!--')).toBe(true); // sanity: this IS the comment-led shape
expect(purifyHtml(fixtureHtml)).toBe(preTask25FixtureOutput);
});
test('a short comment-led, style-free block matches pre-Task-25 output exactly (including the dropped leading whitespace quirk)', () => {
// Confirmed independently against 6a9b227's exact code: a multi-line
// leading comment followed by blank-line whitespace, with no <style>
// anywhere, produces "<p>hi</p>" -- both the comment AND the
// whitespace between it and <p> are dropped by the parser's
// "before head" insertion-mode rules (unrelated to this task; that's
// the pre-existing, unconditional behavior with FORCE_BODY off). The
// point of this test is that the NEW code must reproduce that exact
// old quirk byte-for-byte for style-free input, not "improve" on it.
const commentLed =
'<!-- ============================================================\n' +
' HTML test fixture header\n' +
' ============================================================ -->\n' +
'\n<p>hi</p>';
expect(purifyHtml(commentLed)).toBe('<p>hi</p>');
});
test('plain style-free inputs (no comment involved) still match pre-Task-25 output', () => {
expect(purifyHtml('<p>hello</p>')).toBe('<p>hello</p>');
expect(purifyHtml('<p style="color: #ff0000">red text</p>')).toBe('<p style="color: #ff0000">red text</p>');
});
});
describe('purifyHtml -- review finding: never throws, even on pathological deeply-nested @media input', () => {
function buildDeeplyNestedMedia(count: number): string {
// ~7000 nested @media blocks (the review's exact repro shape) reproduced
// through the REAL purifyHtml() call, not just scopeCss() in isolation
// -- proving the fix holds end-to-end through DOMPurify + scopeStyleBlocks,
// not merely in the unit-tested function.
let css = 'h1{color:red}';
for (let i = 0; i < count; i++) css = `@media (min-width: 1px) {${css}}`;
return `<style>${css}</style><h1>x</h1>`;
}
test('~7000 levels of nested @media does not crash purifyHtml (was: RangeError: Maximum call stack size exceeded)', () => {
const code = buildDeeplyNestedMedia(7000);
expect(() => purifyHtml(code)).not.toThrow();
const out = purifyHtml(code);
expect(out).toContain('<h1>x</h1>');
expect(out).toContain('@media');
});
test('a scope class + wrapper is still produced for the pathological input (best-effort, not a silent no-op)', () => {
const code = buildDeeplyNestedMedia(7000);
const out = purifyHtml(code);
expect(out).toMatch(/^<div class="whp-html-[0-9a-z]+">/);
});
});
describe('purifyHtml -- review finding: idempotent over its own prior output', () => {
test('running purifyHtml() twice (customer pastes previously-published output into a fresh block) does not nest a second wrapper', () => {
const code = '<style>h1 { color: red; }</style><h1>Hi</h1>';
const once = purifyHtml(code);
const twice = purifyHtml(once);
expect(twice).toBe(once);
// Specifically: no second wrapper div, no double-prefixed selector.
expect((twice.match(/<div class="whp-html-/g) || []).length).toBe(1);
});
test('idempotent for a block using :root/media too', () => {
const code = '<style>:root{--x:1} @media (min-width: 600px) { h1, p { color: red; } }</style><h1>Hi</h1><p>x</p>';
const once = purifyHtml(code);
const twice = purifyHtml(once);
expect(twice).toBe(once);
expect((twice.match(/<div class="whp-html-/g) || []).length).toBe(1);
});
test('three generations (paste published output into a block, publish again, paste THAT) stay stable', () => {
const code = '<style>h1{color:red}</style><h1>Hi</h1>';
const gen1 = purifyHtml(code);
const gen2 = purifyHtml(gen1);
const gen3 = purifyHtml(gen2);
expect(gen3).toBe(gen1);
});
});
@@ -1,5 +1,5 @@
import { describe, test, expect } from 'vitest';
import { HtmlBlock } from './HtmlBlock';
import { HtmlBlock, purifyHtml } from './HtmlBlock';
const toHtml = (HtmlBlock as any).toHtml;
@@ -10,4 +10,60 @@ describe('HtmlBlock.toHtml sanitizes raw code (A4.1)', () => {
expect(html).not.toContain('onclick');
expect(html).toContain('<p>hi</p>');
});
test('does not wrap output in an unsanitized element carrying the style prop raw', () => {
// toHtml only ever returns the sanitized `code` blob -- there is no
// wrapper <div style="..."> in the exported HTML, so a malicious
// `style` prop (e.g. an attacker-controlled object with a breakout
// toString()) has nothing to splice into.
const malicious = { toString: () => 'color:red" onmouseover="alert(1)' } as any;
const { html } = toHtml({ code: '<p>hi</p>', style: malicious }, '');
expect(html).not.toMatch(/onmouseover/);
expect(html).not.toMatch(/<div/);
expect(html).toBe('<p>hi</p>');
});
});
test('toHtml never emits the style prop (the other half of the render/export contract)', () => {
const out = (HtmlBlock as any).toHtml(
{ code: '<p>hi</p>', style: { backgroundColor: '#ff0000', padding: '40px' } },
'',
);
expect(out.html).toBe('<p>hi</p>');
expect(out.html).not.toContain('background');
expect(out.html).not.toContain('40px');
});
describe('HtmlBlock.toHtml markup path (C1 review finding)', () => {
test('a style attribute inside `code` (e.g. from the toolbar colour picker) reaches exported output', () => {
const { html } = toHtml({ code: '<p style="color: #ff0000">red text</p>' }, '');
expect(html).toBe('<p style="color: #ff0000">red text</p>');
});
test('a table inside `code` reaches exported output', () => {
const code = '<table><tbody><tr><td>Cell</td></tr></tbody></table>';
const { html } = toHtml({ code }, '');
expect(html).toBe(code);
});
});
describe('HtmlBlock.toHtml -- Task 25: block-scoped <style>, and editor/export byte-parity', () => {
test('a <style>-bearing block exports the same scoped wrapper purifyHtml() would produce in the editor canvas', () => {
// The editor canvas (HtmlBlock component) and toHtml() (Preview +
// Published export) both call the exact same purifyHtml(code) -- this
// is the byte-parity invariant this project treats as a hard
// requirement. Proven here by calling purifyHtml directly (as the
// canvas's useMemo does) and toHtml (as export does) on the identical
// code string and asserting the two never diverge.
const code = '<style>h1 { color: red; }</style><h1>Hi</h1>';
const { html } = toHtml({ code }, '');
expect(html).toBe(purifyHtml(code));
});
test('a <style>-free block still exports byte-identical to pre-Task-25 output (no wrapper regression) via toHtml', () => {
const code = '<p>hello</p>';
const { html } = toHtml({ code }, '');
expect(html).toBe('<p>hello</p>');
expect(html).not.toContain('<div');
});
});
+342 -8
View File
@@ -1,6 +1,8 @@
import React, { CSSProperties, useMemo } from 'react';
import { useNode, UserComponent } from '@craftjs/core';
import DOMPurify from 'dompurify';
import { stableHash } from '../../utils/escape';
import { scopeCss } from '../../utils/scope-css';
interface HtmlBlockProps {
code: string;
@@ -9,41 +11,373 @@ interface HtmlBlockProps {
node_id?: string;
}
// Task 24: widening the allow-list after a customer's broad HTML fixture
// showed 38% of it silently deleted (tables losing colspan/rowspan/scope,
// <dl>/<sub>/<details>/inline <svg>/<video>/<audio> dropped wholesale,
// lang/dir/role stripped, <ol start/reversed> flattened). The owner's call:
// be generous -- this block is an explicit escape hatch and customers
// reasonably expect it to render ordinary HTML, including forms. The four
// non-negotiables (no <script>, no on*, no javascript: URLs, iframes stay
// sandboxed) are unaffected by the widening and are covered by dedicated
// tests in HtmlBlock.test.ts / HtmlBlock.security.test.ts.
const PURIFY_CONFIG = {
ALLOWED_TAGS: [
'a','p','br','hr','div','span','section','article',
'header','footer','main','aside','nav',
'ul','ol','li',
'h1','h2','h3','h4','h5','h6',
'h1','h2','h3','h4','h5','h6','hgroup',
'em','strong','b','i','u','s',
'blockquote','code','pre',
'img','figure','figcaption',
'iframe',
// Tables: pasted content commonly includes these; dropping them
// silently ate customer-pasted tables (see C1 review finding).
'table','thead','tbody','tfoot','tr','td','th','caption','colgroup','col',
// Text semantics (Task 24).
'sub','sup','small','mark','del','ins','abbr','cite','q','time','data',
'kbd','samp','var','dfn','address','bdi','bdo','ruby','rt','rp','wbr',
// Lists (Task 24).
'dl','dt','dd','menu',
// Disclosure widget (Task 24). Note: <dialog> and <template> are
// deliberately NOT added -- the fixture exercises them wrapped in
// on*= handlers specifically to prove they still get neutralized/
// dropped by staying outside the allow-list.
'details','summary',
// Media (Task 24). URL-bearing attributes on these (poster, srcset,
// action, cite...) go through the ALLOWED_URI_REGEXP gate like
// everything else -- see _isValidAttribute in dompurify, which
// URI-checks every allowed attribute value except a small fixed
// "inert" list (alt, class, id, style, title, ...) that never includes
// src/poster/srcset. The one exception: `src` itself on img/video/
// audio/source/image/track is additionally covered by DOMPurify's own
// `DATA_URI_TAGS` allow-list, which accepts any data: URI on those
// tag/attribute pairs regardless of mimetype, bypassing this regex --
// see the ALLOWED_URI_REGEXP comment below and
// HtmlBlock.security.test.ts. Not a gap in the four non-negotiables:
// none of those tags execute their src as a document.
'picture','source','video','audio','track','canvas',
// Forms (Task 24). Site owner's explicit decision: allow the full
// ordinary form surface. No on*= survives (FORBID_ATTR below), and
// action/formaction-style URLs are gated by ALLOWED_URI_REGEXP the
// same as href/src, so `javascript:` still cannot survive here either.
'form','input','button','select','option','optgroup','textarea',
'label','fieldset','legend','datalist','output','progress','meter',
// Inline SVG (Task 24) -- see the block comment on IFRAME_SANDBOX_HOOK's
// neighbor below for why this is an explicit tag list rather than
// DOMPurify's USE_PROFILES svg profile. Deliberately excludes <use> and
// <image> (both need xlink:href, an external-reference vector DOMPurify
// itself excludes from its own SVG defaults) and <a>/<foreignObject>
// (not needed by the fixture; foreignObject can embed arbitrary HTML).
'svg','g','defs','symbol','title','desc','rect','circle','ellipse',
'line','polyline','polygon','path','text','tspan',
'lineargradient','radialgradient','stop','clippath','mask','marker',
'pattern','switch','view',
// Task 25: block-scoped <style> support. Formerly in FORBID_TAGS
// (stripped entirely). Now allowed through sanitisation -- its CSS is
// rewritten by scopeStyleBlocks()/scopeCss() below, immediately after
// DOMPurify runs, so it can only match inside this block's own wrapper
// element. See the FORCE_BODY comment below and scopeStyleBlocks() for
// why allowing the tag alone is not sufficient.
//
// Review note (Task 25 follow-up, documented not fixed): DOMPurify's
// SAFE_FOR_XML default (on unless a caller explicitly disables it,
// which PURIFY_CONFIG does not) silently drops an ENTIRE <style>
// element -- not just the offending part -- if its text content
// contains anything that merely LOOKS tag-like (a `<` followed by a
// word character, `/`, or `!`), as an mXSS-namespace-confusion defense
// that isn't specific to <style>. So `.x::after{content:"<Read
// More>"}` -- a plausible, entirely benign real-world CSS content
// string -- makes the whole style block vanish with no error, the same
// way a `<script>` would. This is a GOOD security property (better
// paranoid than exploitable), but it's an undocumented interaction
// with this newly-widened surface that will otherwise confuse whoever
// debugs the inevitable "my CSS just disappeared" report -- confirmed
// empirically against dompurify+jsdom directly, not guessed at.
'style',
],
// NOTE: supplying ALLOWED_ATTR replaces DOMPurify's own default attribute
// allowlist rather than extending it, so anything the product needs
// (style, id, ...) must be listed explicitly here even though DOMPurify
// would allow it by default.
ALLOWED_ATTR: [
'href','src','alt','title','target','rel',
'width','height','class',
'width','height','class','id','style',
'allowfullscreen','allow','frameborder',
'sandbox','referrerpolicy',
// Task 24 additions.
'colspan','rowspan','scope','headers','span','start','reversed',
'type','value','name','placeholder','required','disabled','readonly',
'checked','selected','multiple','size','min','max','step','minlength',
'maxlength','pattern','rows','cols','accept','action','method','for',
'list','label','datetime','cite','lang','dir','role','srcset','media',
'sizes','loading','controls','poster','loop','muted','autoplay',
'preload','playsinline','kind','srclang','default','open','download',
'hidden','contenteditable',
// Bug fix: <select size="4">/<input size> and <meter low/high/optimum>
// were still being stripped even though <select>/<meter> are already in
// ALLOWED_TAGS -- only these four attribute names were missing here.
// Effect: a multi-select rendered at default height instead of the
// requested row count, and <meter> lost its threshold-based gauge
// colouring. Pure presentation/semantic attributes -- no URL, no
// script, no event-handler surface -- so no security weight added.
'low','high','optimum',
// SVG presentation attributes (explicit route -- see ALLOWED_TAGS
// comment on the SVG tag list). Covers the fixture's <svg viewBox
// role>/<rect>/<circle>/<text> block plus the common presentation
// attributes for the shapes/gradients allowed above. Deliberately
// excludes xlink:href (no <use>/<image> allowed, so it has nothing
// legitimate to attach to) and the SMIL/animation attributes (begin,
// dur, repeatCount, ...) which DOMPurify's own SVG defaults exclude
// for the same reason on* handlers are excluded.
'viewbox','cx','cy','r','rx','ry','x','y','x1','y1','x2','y2',
'points','d','fill','stroke','stroke-width','stroke-linecap',
'stroke-linejoin','stroke-dasharray','fill-rule','clip-rule','opacity',
'fill-opacity','stroke-opacity','text-anchor','dominant-baseline',
'font-family','font-size','font-weight','transform','offset',
'stop-color','stop-opacity','gradientunits','gradienttransform',
'preserveaspectratio',
],
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'],
// Review fix (Task 24 follow-up): the data:image arm used to sit inside
// the group that gets a trailing `:` appended for every alternative
// (`(?:https?|mailto|tel|data:image\/[a-z]+;base64,):`), so it required
// a SECOND colon after the one already in "base64,figure" -- no real
// data URI has that, so the clause could never match. It is now its own
// top-level alternative. NOTE: this regex is not the only thing gating
// data: URIs -- DOMPurify has its own internal `DATA_URI_TAGS` allow-list
// (img/video/audio/source/image/track) that accepts ANY data: URI on
// those tag/attribute pairs regardless of declared mimetype, bypassing
// this regex entirely. See HtmlBlock.security.test.ts for a regression
// test documenting that (acceptable: none of those tags execute their
// src as a document in mainstream browsers, and <iframe> -- which would
// be dangerous -- is correctly not in that DOMPurify list).
ALLOWED_URI_REGEXP: /^(?:(?:https?|mailto|tel):|data:image\/[a-z]+;base64,|[^a-z]|[a-z+.-]+(?:[^a-z+.\-:]|$))/i,
// form/input/button/select/textarea removed from FORBID_TAGS (Task 24) --
// they are now deliberately allowed above. script/object/embed/link/meta
// stay forbidden. <style> (Task 25) is now allowed too -- see ALLOWED_TAGS
// comment above and scopeStyleBlocks() below; it survives sanitisation
// here but its CSS gets scoped afterwards, including copies nested inside
// the newly-allowed inline <svg> (querySelectorAll('style') in
// scopeStyleBlocks() doesn't care about namespace/nesting depth).
FORBID_TAGS: ['script','object','embed','link','meta'],
FORBID_ATTR: [/^on/i],
// NOTE: FORCE_BODY is deliberately NOT set here -- see
// needsForceBody()/purifyHtml() below. It's applied conditionally, per
// call, only when the input actually has a real <style> tag to rescue.
};
export function purifyHtml(input: string): string {
return DOMPurify.sanitize(input || '', PURIFY_CONFIG as any) as unknown as string;
// Task 25: without FORCE_BODY, DOMPurify parses `input` as a full (mini)
// HTML document via DOMParser and only serializes <body>'s contents. Per
// the HTML5 parsing algorithm, a tag that can only legally appear in
// <head> -- and now that <style> is allowed, that includes <style> --
// gets implicitly placed in <head> when it appears before any other real
// content, and is silently lost (DOMPurify never looks at <head>). A block
// whose entire `code` is `<style>h1{color:red}</style>` -- a very
// plausible paste, style-before-markup is a common snippet shape -- would
// vanish with no error anywhere, despite <style> sitting right there in
// ALLOWED_TAGS. FORCE_BODY prepends an internal element before parsing so
// the parser is already in body-insertion-mode by the time it reaches the
// customer's first tag, keeping a leading <style> (or anything else) in
// <body> where DOMPurify's body-only serialization actually looks.
// Confirmed empirically against dompurify+jsdom directly (not just this
// app's behavior) -- see the "leading <style> with nothing before it" test
// in HtmlBlock.test.ts.
//
// Review finding (Task 25 follow-up): FORCE_BODY is NOT a no-op for input
// that has no <style> tag at all. It also changes how the HTML parser
// treats character content sitting between a LEADING comment and the next
// real tag -- normal parsing (before <body> is established) silently drops
// pure-whitespace text runs there per the HTML5 "before head" insertion
// mode rules, while FORCE_BODY (already in body-insertion-mode from the
// first token) preserves that whitespace as a real text node. Concretely:
// a block starting with a multi-line HTML comment -- this repo's own
// ~16KB fixture does exactly that -- gained 2 extra leading bytes (a
// preserved newline) once FORCE_BODY was unconditionally on, which
// silently broke the "blocks without <style> are byte-identical to
// pre-Task-25 output" guarantee (confirmed with a raw diff against
// HtmlBlock.tsx@6a9b227 -- the commit immediately before this task -- over
// the fixture and a comment-led block; see HtmlBlock.test.ts). Fix: only
// ever set FORCE_BODY when the input has a real <style> tag to rescue --
// the one and only case that needs it -- so every other input takes
// exactly the pre-Task-25 code path, unchanged.
//
// "Real" deliberately excludes a `<style` substring that only appears
// inside an HTML comment (e.g. a customer's own code-sample text
// mentioning `<style>`) -- that text can never become an actual <style>
// element, but naively substring-matching it would still flip FORCE_BODY
// on and reintroduce the exact same whitespace-preservation side effect
// for a block that never had, and never needed, real style scoping.
const STYLE_TAG_RE = /<style[\s>/]/i;
const HTML_COMMENT_RE = /<!--[\s\S]*?-->/g;
function needsForceBody(input: string): boolean {
return STYLE_TAG_RE.test(input.replace(HTML_COMMENT_RE, ''));
}
export const HtmlBlock: UserComponent<HtmlBlockProps> = ({ code = '', style = {} }) => {
// 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');
}
};
/**
* Task 25: rewrite any surviving `<style>` element(s) in `sanitized` (the
* DOMPurify output) so their CSS only matches inside this block's own
* wrapper element, then wrap the whole thing in that wrapper.
*
* Deliberately does the LEAST work possible when there's nothing to scope:
* a cheap substring check bails out before touching the DOM at all, so a
* block that doesn't use <style> -- i.e. every block saved before this task
* -- gets `sanitized` back completely unchanged (same string, no wrapper,
* no re-serialization round-trip that could subtly reformat attributes).
* That byte-for-byte identity is a hard requirement: published pages
* already contain `toHtml()` output with NO wrapper element, and adding one
* unconditionally would silently change the DOM/box-model of every
* existing customer block. See HtmlBlock.test.ts's
* "blocks without <style> are byte-identical" tests, which run this
* against real fixture content and diff the exact string.
*
* Scope identifier: `whp-html-${stableHash(rawCode)}` -- `stableHash` is
* the existing djb2 hash from utils/escape.ts (already used for this exact
* class of problem, see `scopeId` in that file), applied to `rawCode` --
* the block's own `code` prop, nothing else. Pure function of the block's
* own content: no Math.random, no Date.now, no counter, and deliberately
* NOT the Craft node id (unlike `scopeId`), because a scope identifier that
* depends on anything outside `code` would make the editor canvas preview
* (which calls purifyHtml(code) on render) and the published output (which
* calls the same purifyHtml(code) at publish time) diverge whenever that
* outside thing differs between the two call sites, and would make the
* stored HTML churn on every save even when the block's own content didn't
* change. Hashing `code` guarantees purifyHtml(code) is fully deterministic
* on its own -- same code in, byte-identical output out, every time, in
* both places it's called.
*/
const SCOPE_CLASS_RE = /^whp-html-[0-9a-z]+$/;
/**
* Idempotency (review finding, Task 25 follow-up): `purifyHtml()` is not
* reachable-with-its-own-output through any CURRENT code path, but nothing
* stops a customer from pasting previously-published or exported HTML from
* this exact feature into a fresh Custom HTML block -- at which point
* `code` already contains our own `<div class="whp-html-OLD">...<style>
* .whp-html-OLD h1{...}</style>...</div>` wrapper. Without this check,
* `scopeStyleBlocks` would hash the NEW `code` to a NEW scope class, fail
* to recognise the embedded selectors as already scoped (they're prefixed
* for the OLD class, not the new one `scopeCss`'s own idempotency guard
* checks against), and nest a second wrapper div around the first while
* re-prefixing every selector under the new class on top of the old one.
*
* Detects "the sanitized content IS ALREADY exactly one of our own scoped
* wrappers": a single root element, a <div>, whose class matches our own
* naming convention, and whose `<style>` descendant(s) are each already a
* no-op under `scopeCss` for that div's own class -- i.e. re-scoping would
* change nothing. That last check reuses `scopeCss`'s own idempotency
* guarantee (`scopeCss(scopeCss(x, S), S) === scopeCss(x, S)`, proved in
* scope-css.test.ts) rather than re-implementing "is this CSS already
* scoped" as a second parser: if scoping again under the div's own class
* is a no-op, the CSS is already confined to that div, regardless of
* whether this app was the one that put it there -- which is the actual
* safety property this function exists to guarantee, not merely a proxy
* for it.
*/
function isAlreadyScoped(container: HTMLElement): boolean {
if (container.children.length !== 1) return false;
const root = container.children[0];
if (root.tagName !== 'DIV') return false;
const cls = root.getAttribute('class') || '';
if (!SCOPE_CLASS_RE.test(cls)) return false;
const scopeSelector = `.${cls}`;
const styleEls = Array.from(root.querySelectorAll('style'));
if (styleEls.length === 0) return false; // matches our naming by coincidence but scopes nothing -- not ours to protect
return styleEls.every((el) => {
const text = el.textContent || '';
if (text.trim() === '') return true;
return scopeCss(text, scopeSelector) === text;
});
}
function scopeStyleBlocks(sanitized: string, rawCode: string): string {
if (!sanitized.includes('<style')) return sanitized;
const container = document.createElement('div');
container.innerHTML = sanitized;
if (isAlreadyScoped(container)) return sanitized;
const styleEls = Array.from(container.querySelectorAll('style'));
const nonEmpty = styleEls.filter((el) => (el.textContent || '').trim() !== '');
if (nonEmpty.length === 0) return sanitized;
// Review note (Task 25 follow-up, documented not fixed): `stableHash` is
// a 32-bit djb2 hash, so it's brute-forceable in principle -- a customer
// could deliberately craft a second block's `code` to collide onto the
// same `whp-html-<hash>` class as an existing block on the same page, at
// which point the two blocks' <style> rules apply to (and override) each
// other, since they'd share one wrapper class. Impact is CSS-only --
// visual breakage, never script execution or data exposure -- the same
// trust tier as other accepted risks in this file (e.g. remote url() in
// style content, or the pre-existing DATA_URI_TAGS mimetype-blindness
// documented in HtmlBlock.security.test.ts). Not fixed here: closing it
// would mean either a wider hash (cheap, but every existing scope class
// set with THIS Task 25 code would silently reshuffle -- a similar
// "changing the hash function reshuffles stored HTML" cost the pinned
// hash test above already guards against happening BY ACCIDENT) or a
// collision-checked/salted scheme, either of which is a bigger design
// decision than a follow-up-review fix.
const scopeClass = `whp-html-${stableHash(rawCode)}`;
for (const el of nonEmpty) {
el.textContent = scopeCss(el.textContent || '', `.${scopeClass}`);
}
return `<div class="${scopeClass}">${container.innerHTML}</div>`;
}
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 {
const raw = input || '';
// See needsForceBody()/the FORCE_BODY comment above PURIFY_CONFIG:
// applied only when there's a real <style> tag to rescue, so every
// other input takes the exact pre-Task-25 sanitize() call, unchanged.
const config = needsForceBody(raw) ? { ...PURIFY_CONFIG, FORCE_BODY: true } : PURIFY_CONFIG;
const sanitized = DOMPurify.sanitize(raw, config as any) as unknown as string;
return scopeStyleBlocks(sanitized, raw);
} finally {
DOMPurify.removeHook('afterSanitizeAttributes', IFRAME_SANDBOX_HOOK as any);
}
}
export const HtmlBlock: UserComponent<HtmlBlockProps> = ({ code = '' }) => {
const { connectors: { connect, drag }, selected } = useNode((node) => ({ selected: node.events.selected }));
const clean = useMemo(() => purifyHtml(code), [code]);
const setRef = (ref: HTMLElement | null): void => { if (ref) connect(drag(ref)); };
// The `style` prop is deliberately NOT applied. `toHtml()` emits only the
// purified `code`, so anything styled here would show in the editor and
// vanish on the live page -- the exact bug this block was reported for.
// Wrapper styling belongs in the user's own markup (see the Edit HTML
// toolbar's colour control). `style` stays on the props interface so
// already-saved sites keep deserializing cleanly.
return React.createElement('div', {
ref: setRef,
style: {
minHeight: '40px',
outline: selected ? '2px solid #3b82f6' : 'none',
...style,
},
dangerouslySetInnerHTML: { __html: clean },
});
@@ -0,0 +1,72 @@
import { describe, test, expect } from 'vitest';
import { Icon } from './Icon';
const toHtml = (Icon as any).toHtml;
describe('Icon.toHtml normal rendering', () => {
test('renders icon class, size/color style, and link href', () => {
const { html } = toHtml({ icon: 'fa-star', size: '32px', color: '#3b82f6', link: 'https://example.com' }, '');
expect(html).toContain('class="fa fa-star"');
expect(html).toContain('font-size:32px');
expect(html).toContain('color:#3b82f6');
expect(html).toContain('href="https://example.com"');
});
});
describe('Icon.toHtml XSS hardening', () => {
test('an icon name with an attribute-breakout string is escaped, not raw-concatenated', () => {
const malicious = 'star"><script>alert(1)</script>';
const { html } = toHtml({ icon: malicious as any }, '');
expect(html).not.toContain('<script>alert(1)</script>');
expect(html).not.toMatch(/class="fa star"><script>/);
});
test('a size value with an attribute-breakout string cannot escape style=""', () => {
const malicious = '24px" onerror="alert(1)';
const { html } = toHtml({ size: malicious as any }, '');
expect(html).not.toMatch(/"\s+onerror="/);
});
test('a bgSize/bgColor breakout via background wrapper is neutralized', () => {
const malicious = '56px" onmouseover="alert(1)';
const { html } = toHtml({ bgShape: 'circle', bgColor: '#fff', bgSize: malicious as any }, '');
expect(html).not.toMatch(/"\s+onmouseover="/);
});
test('a javascript: link is neutralized to an empty href', () => {
const { html } = toHtml({ link: 'javascript:alert(1)' }, '');
expect(html).not.toContain('javascript:alert(1)');
});
test('a link value with an attribute-breakout string cannot escape href=""', () => {
const malicious = 'https://example.com" onclick="alert(1)';
const { html } = toHtml({ link: malicious as any }, '');
expect(html).not.toMatch(/"\s+onclick="/);
});
});
describe('Icon.toHtml bgShape/bgColor/bgSize rendering (built but, until this panel update, unexposed)', () => {
test('bgShape="circle" + bgColor render a colored 50%-radius background box', () => {
const { html } = toHtml({ icon: 'fa-star', bgShape: 'circle', bgColor: '#3b82f6', bgSize: '64px' }, '');
expect(html).toContain('background-color:#3b82f6');
expect(html).toContain('border-radius:50%');
expect(html).toContain('width:64px');
expect(html).toContain('height:64px');
});
test('bgShape="none" (default) renders the bare icon with no background wrapper', () => {
const { html } = toHtml({ icon: 'fa-star' }, '');
expect(html).not.toContain('background-color');
});
});
describe('Icon.craft.props includes the box-model/animation/visibility rollout props', () => {
test('animation, animationDelay, and all 3 hideOn* flags are declared (blank/false defaults)', () => {
const props = (Icon as any).craft.props;
expect(props).toHaveProperty('animation', '');
expect(props).toHaveProperty('animationDelay', '');
expect(props).toHaveProperty('hideOnDesktop', false);
expect(props).toHaveProperty('hideOnTablet', false);
expect(props).toHaveProperty('hideOnMobile', false);
});
});
+10
View File
@@ -12,6 +12,11 @@ interface IconProps {
bgSize?: string;
link?: string;
style?: CSSProperties;
animation?: string;
animationDelay?: string;
hideOnDesktop?: boolean;
hideOnTablet?: boolean;
hideOnMobile?: boolean;
}
function getBgBorderRadius(shape: string): string {
@@ -96,6 +101,11 @@ Icon.craft = {
bgSize: '56px',
link: '',
style: {},
animation: '',
animationDelay: '',
hideOnDesktop: false,
hideOnTablet: false,
hideOnMobile: false,
},
rules: {
canDrag: () => true,
@@ -0,0 +1,85 @@
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 download attribute (F3: link-to-file toggle)', () => {
test('download:true emits the download attribute', () => {
const { html } = toHtml({ href: '/resume.pdf', download: true, text: 'Resume' }, '');
expect(html).toMatch(/<a href="\/resume\.pdf" download/);
});
test('no download prop -> no download attribute emitted', () => {
const { html } = toHtml({ href: '/', text: 'MySite' }, '');
expect(html).not.toContain('download');
});
});
describe('Logo (F4: box-model + animation + visibility props on craft.props)', () => {
test('craft.props includes animation/visibility defaults so the panel controls always render', () => {
const craftProps = (Logo as any).craft.props;
expect(craftProps).toHaveProperty('animation', 'none');
expect(craftProps).toHaveProperty('animationDelay', '0');
expect(craftProps).toHaveProperty('hideOnDesktop', false);
expect(craftProps).toHaveProperty('hideOnTablet', false);
expect(craftProps).toHaveProperty('hideOnMobile', false);
});
});
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');
});
});
+19 -3
View File
@@ -2,7 +2,7 @@ import React, { CSSProperties } from 'react';
import { useNode, UserComponent } from '@craftjs/core';
import { cssPropsToString } from '../../utils/style-helpers';
import { useSiteDesign } from '../../state/SiteDesignContext';
import { escapeHtml, escapeAttr, safeUrl } from '../../utils/escape';
import { escapeHtml, escapeAttr, safeUrl, safeImageUrl } from '../../utils/escape';
/* ---------- Types ---------- */
@@ -12,11 +12,18 @@ interface LogoProps {
imageSrc?: string;
imageWidth?: string;
href?: string;
/** Adds the `download` attribute to the exported anchor (F3: link to a file). */
download?: boolean;
fontFamily?: string;
fontSize?: string;
fontWeight?: string;
color?: string;
style?: CSSProperties;
hideOnDesktop?: boolean;
hideOnTablet?: boolean;
hideOnMobile?: boolean;
animation?: string;
animationDelay?: string;
}
/* ---------- Component ---------- */
@@ -27,6 +34,7 @@ export const Logo: UserComponent<LogoProps> = ({
imageSrc = '',
imageWidth = '120px',
href = '/',
download = false,
fontFamily = 'Inter, sans-serif',
fontSize = '20px',
fontWeight = '700',
@@ -44,6 +52,7 @@ export const Logo: UserComponent<LogoProps> = ({
<a
ref={(ref: HTMLElement | null): void => { if (ref) connect(drag(ref)); }}
href={href}
download={download || undefined}
onClick={(e) => e.preventDefault()}
style={{
textDecoration: 'none',
@@ -87,7 +96,13 @@ Logo.craft = {
fontSize: '20px',
fontWeight: '700',
color: undefined,
download: false,
style: {},
hideOnDesktop: false,
hideOnTablet: false,
hideOnMobile: false,
animation: 'none',
animationDelay: '0',
} as LogoProps,
rules: {
canDrag: () => true,
@@ -104,7 +119,7 @@ Logo.craft = {
let innerHtml: string;
if (props.type === 'image' && props.imageSrc) {
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 {
const spanStyle = cssPropsToString({
fontWeight: props.fontWeight || '700',
@@ -122,8 +137,9 @@ Logo.craft = {
flexShrink: '0',
...props.style,
});
const downloadAttr = props.download ? ' download' : '';
return {
html: `<a href="${escapeAttr(safeUrl(href))}"${aStyle ? ` style="${aStyle}"` : ''}>${innerHtml}</a>`,
html: `<a href="${escapeAttr(safeUrl(href))}"${downloadAttr}${aStyle ? ` style="${aStyle}"` : ''}>${innerHtml}</a>`,
};
};
@@ -30,3 +30,40 @@ describe('Menu.toHtml deterministic + unique scope ids (thread node id, no Math.
expect(html1).toBe(html2);
});
});
describe('Menu.toHtml download attribute (F3: link-to-file toggle)', () => {
test('a link with download:true emits the download attribute', () => {
const { html } = toHtml({ links: [{ text: 'Brochure', href: '/brochure.pdf', download: true }] }, '', 'node-dl1');
expect(html).toMatch(/<a href="\/brochure\.pdf"[^>]* download[^>]*>Brochure<\/a>/);
});
test('a link without download does not emit the attribute', () => {
const { html } = toHtml({ links: [{ text: 'Home', href: '/' }] }, '', 'node-dl2');
expect(html).not.toContain(' download');
});
});
describe('Menu (F4: box-model + animation + visibility props on craft.props)', () => {
test('craft.props includes animation/visibility defaults so the panel controls always render', () => {
const craftProps = (Menu as any).craft.props;
expect(craftProps).toHaveProperty('animation', 'none');
expect(craftProps).toHaveProperty('animationDelay', '0');
expect(craftProps).toHaveProperty('hideOnDesktop', false);
expect(craftProps).toHaveProperty('hideOnTablet', false);
expect(craftProps).toHaveProperty('hideOnMobile', false);
});
});
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/);
});
});
+26 -8
View File
@@ -1,7 +1,7 @@
import React, { CSSProperties, useState } from 'react';
import { useNode, UserComponent } from '@craftjs/core';
import { cssPropsToString } from '../../utils/style-helpers';
import { escapeHtml, escapeAttr, safeUrl, scopeId } from '../../utils/escape';
import { escapeHtml, escapeAttr, safeUrl, scopeId, cssValue } from '../../utils/escape';
/* ---------- Types ---------- */
@@ -10,6 +10,8 @@ interface MenuLink {
href: string;
isExternal?: boolean;
isCta?: boolean;
/** Adds the `download` attribute to the exported anchor (F3: links to files). */
download?: boolean;
}
interface MenuProps {
@@ -23,6 +25,11 @@ interface MenuProps {
orientation?: 'horizontal' | 'vertical';
fontSize?: string;
style?: CSSProperties;
hideOnDesktop?: boolean;
hideOnTablet?: boolean;
hideOnMobile?: boolean;
animation?: string;
animationDelay?: string;
}
/* ---------- Defaults ---------- */
@@ -75,6 +82,7 @@ export const Menu: UserComponent<MenuProps> = ({
href={link.href}
target={link.isExternal ? '_blank' : undefined}
rel={link.isExternal ? 'noopener noreferrer' : undefined}
download={link.download || undefined}
onClick={(e) => e.preventDefault()}
onMouseEnter={() => setHoveredLink(i)}
onMouseLeave={() => setHoveredLink(null)}
@@ -114,6 +122,11 @@ Menu.craft = {
orientation: 'horizontal',
fontSize: '14px',
style: {},
hideOnDesktop: false,
hideOnTablet: false,
hideOnMobile: false,
animation: 'none',
animationDelay: '0',
} as MenuProps,
rules: {
canDrag: () => true,
@@ -125,14 +138,18 @@ Menu.craft = {
/* ---------- HTML export ---------- */
(Menu as any).toHtml = (props: MenuProps, _childrenHtml: string, nodeId?: string) => {
const linkCol = props.linkColor || '#3f3f46';
const hoverCol = props.linkHoverColor || '#3b82f6';
const ctaBg = props.ctaBgColor || '#3b82f6';
const ctaText = props.ctaTextColor || '#ffffff';
const gap = props.gap || '24px';
// Sanitized once here -- linkCol/hoverCol/ctaBg/ctaText/gap/fSize are raw
// string-interpolation sinks below (hoverCol goes into a <style> block,
// the worst case: </style> breakout -> arbitrary <script>), see
// task-cssxss-brief.md.
const linkCol = cssValue(props.linkColor) || '#3f3f46';
const hoverCol = cssValue(props.linkHoverColor) || '#3b82f6';
const ctaBg = cssValue(props.ctaBgColor) || '#3b82f6';
const ctaText = cssValue(props.ctaTextColor) || '#ffffff';
const gap = cssValue(props.gap) || '24px';
const orientation = props.orientation || 'horizontal';
const alignment = props.alignment || 'right';
const fSize = props.fontSize || '14px';
const fSize = cssValue(props.fontSize) || '14px';
const justifyMap: Record<string, string> = { left: 'flex-start', center: 'center', right: 'flex-end' };
@@ -158,6 +175,7 @@ Menu.craft = {
const linksHtml = links.map((link) => {
const target = link.isExternal ? ' target="_blank" rel="noopener noreferrer"' : '';
const downloadAttr = link.download ? ' download' : '';
const cls = link.isCta ? `${scope}-cta` : `${scope}-link`;
const linkStyle = cssPropsToString({
textDecoration: 'none',
@@ -169,7 +187,7 @@ Menu.craft = {
borderRadius: link.isCta ? '6px' : '0',
transition: 'color 0.15s, background-color 0.15s',
});
return `<a href="${escapeAttr(safeUrl(link.href || '#'))}" class="${cls}"${target}${linkStyle ? ` style="${linkStyle}"` : ''}>${escapeHtml(link.text)}</a>`;
return `<a href="${escapeAttr(safeUrl(link.href || '#'))}" class="${cls}"${target}${downloadAttr}${linkStyle ? ` style="${linkStyle}"` : ''}>${escapeHtml(link.text)}</a>`;
}).join('\n ');
const hoverCss = `<style>
@@ -5,24 +5,138 @@ const toHtml = (Navbar as any).toHtml;
describe('Navbar.toHtml hamburger accessibility (F2.3)', () => {
test('mobile toggle button has an accessible name, aria-expanded, and aria-controls', () => {
const { html } = toHtml({ showMobileMenu: true }, '');
const { html } = toHtml({ showMobileMenu: true }, '', 'node-nav1');
expect(html).toMatch(/class="navbar-hamburger"[^>]*aria-label="Toggle navigation menu"/);
expect(html).toMatch(/aria-expanded="false"/);
expect(html).toMatch(/aria-controls="navbar-links"/);
expect(html).toMatch(/aria-controls="[^"]+"/);
});
test('aria-controls target id exists on the links container', () => {
const { html } = toHtml({ showMobileMenu: true }, '');
expect(html).toContain('id="navbar-links"');
const { html } = toHtml({ showMobileMenu: true }, '', 'node-nav1');
const controls = html.match(/aria-controls="([^"]+)"/)![1];
expect(html).toContain(`id="${controls}"`);
});
test('toggle script flips aria-expanded on click', () => {
const { html } = toHtml({ showMobileMenu: true }, '');
const { html } = toHtml({ showMobileMenu: true }, '', 'node-nav1');
expect(html).toMatch(/setAttribute\(['"]aria-expanded['"]/);
});
test('no mobile menu: no hamburger button emitted', () => {
const { html } = toHtml({ showMobileMenu: false }, '');
const { html } = toHtml({ showMobileMenu: false }, '', 'node-nav1');
expect(html).not.toContain('navbar-hamburger');
});
});
describe('Navbar.toHtml node-scoped ids/hover styles (M-1: two navbars must not collide)', () => {
test('no bare unscoped id="navbar-links" is emitted', () => {
const { html } = toHtml({ showMobileMenu: true }, '', 'node-nav1');
expect(html).not.toContain('id="navbar-links"');
});
test('two different node ids produce different links-container ids', () => {
const { html: html1 } = toHtml({ showMobileMenu: true }, '', 'node-nav1');
const { html: html2 } = toHtml({ showMobileMenu: true }, '', 'node-nav2');
const id1 = html1.match(/id="([^"]+)"/)![1];
const id2 = html2.match(/id="([^"]+)"/)![1];
expect(id1).not.toBe(id2);
});
test('aria-controls always equals the actual links-container id', () => {
const { html } = toHtml({ showMobileMenu: true }, '', 'node-nav1');
const controls = html.match(/aria-controls="([^"]+)"/)![1];
const linksId = html.match(/id="([^"]+)"/)![1];
expect(controls).toBe(linksId);
});
test('hover style selectors are scoped per-instance, not bare .navbar-link/.navbar-cta', () => {
const { html } = toHtml({ hoverColor: '#ff0000' }, '', 'node-nav1');
// A selector rule that STARTS the line with .navbar-link:hover (i.e. not
// preceded by a per-instance ancestor class) would be the old, unscoped,
// globally-colliding form.
expect(html).not.toMatch(/^\s*\.navbar-link:hover/m);
expect(html).not.toMatch(/^\s*\.navbar-cta:hover/m);
// still present, just scoped under a per-instance ancestor class
expect(html).toMatch(/\.navbar-link:hover/);
expect(html).toMatch(/\.[\w-]+ \.navbar-link:hover/);
});
test('two navbars with different hoverColor do not leak style onto each other (scoped selectors differ)', () => {
const { html: html1 } = toHtml({ hoverColor: '#ff0000' }, '', 'node-nav1');
const { html: html2 } = toHtml({ hoverColor: '#00ff00' }, '', 'node-nav2');
const scope1 = html1.match(/<style>\s*\.([\w-]+)\s/)![1];
const scope2 = html2.match(/<style>\s*\.([\w-]+)\s/)![1];
expect(scope1).not.toBe(scope2);
expect(html1).toContain(`.${scope1} .navbar-link:hover`);
expect(html2).toContain(`.${scope2} .navbar-link:hover`);
});
test('a normal single navbar still renders its hover style (visual output preserved)', () => {
const { html } = toHtml({ hoverColor: '#ff0000' }, '', 'node-nav1');
expect(html).toMatch(/:hover\s*\{\s*color:\s*#ff0000/);
});
test('same node id -> identical output across calls (deterministic)', () => {
const { html: html1 } = toHtml({ showMobileMenu: true }, '', 'node-nav1');
const { html: html2 } = toHtml({ showMobileMenu: true }, '', 'node-nav1');
expect(html1).toBe(html2);
});
});
describe('Navbar.toHtml download attribute (F3: link-to-file toggle)', () => {
test('a link with download:true emits the download attribute', () => {
const { html } = toHtml({ links: [{ text: 'Brochure', href: '/brochure.pdf', download: true }] }, '', 'node-dl1');
expect(html).toMatch(/<a href="\/brochure\.pdf"[^>]* download[^>]*>Brochure<\/a>/);
});
test('a link without download does not emit the attribute', () => {
const { html } = toHtml({ links: [{ text: 'Home', href: '/' }] }, '', 'node-dl2');
expect(html).not.toContain(' download');
});
});
describe('Navbar (F4: box-model + animation + visibility props on craft.props)', () => {
test('craft.props includes animation/visibility defaults so the panel controls always render', () => {
const craftProps = (Navbar as any).craft.props;
expect(craftProps).toHaveProperty('animation', 'none');
expect(craftProps).toHaveProperty('animationDelay', '0');
expect(craftProps).toHaveProperty('hideOnDesktop', false);
expect(craftProps).toHaveProperty('hideOnTablet', false);
expect(craftProps).toHaveProperty('hideOnMobile', false);
});
});
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');
});
});
+51 -19
View File
@@ -2,7 +2,7 @@ import React, { CSSProperties, useState } from 'react';
import { useNode, UserComponent } from '@craftjs/core';
import { cssPropsToString } from '../../utils/style-helpers';
import { useSiteDesign } from '../../state/SiteDesignContext';
import { escapeHtml, escapeAttr, safeUrl } from '../../utils/escape';
import { escapeHtml, escapeAttr, safeUrl, safeImageUrl, cssValue, scopeId } from '../../utils/escape';
/* ---------- Types ---------- */
@@ -11,6 +11,8 @@ interface NavLink {
href: string;
isExternal?: boolean;
isCta?: boolean;
/** Adds the `download` attribute to the exported anchor (F3: links to files). */
download?: boolean;
}
interface NavbarProps {
@@ -33,6 +35,11 @@ interface NavbarProps {
isSticky?: boolean;
showMobileMenu?: boolean;
style?: CSSProperties;
hideOnDesktop?: boolean;
hideOnTablet?: boolean;
hideOnMobile?: boolean;
animation?: string;
animationDelay?: string;
}
/* ---------- Defaults ---------- */
@@ -149,6 +156,7 @@ export const Navbar: UserComponent<NavbarProps> = ({
href={link.href}
target={link.isExternal ? '_blank' : undefined}
rel={link.isExternal ? 'noopener noreferrer' : undefined}
download={link.download || undefined}
onClick={(e) => e.preventDefault()}
onMouseEnter={() => setHoveredLink(i)}
onMouseLeave={() => setHoveredLink(null)}
@@ -200,6 +208,11 @@ Navbar.craft = {
style: {
borderBottom: '1px solid #e4e4e7',
},
hideOnDesktop: false,
hideOnTablet: false,
hideOnMobile: false,
animation: 'none',
animationDelay: '0',
} as NavbarProps,
rules: {
canDrag: () => true,
@@ -210,17 +223,33 @@ Navbar.craft = {
/* ---------- HTML export ---------- */
(Navbar as any).toHtml = (props: NavbarProps, _childrenHtml: string) => {
const bgColor = props.backgroundColor || '#ffffff';
const textCol = props.textColor || '#3f3f46';
const hoverCol = props.hoverColor || '#3b82f6';
const ctaCol = props.ctaColor || '#3b82f6';
const ctaTextCol = props.ctaTextColor || '#ffffff';
const pad = props.padding || '16px 24px';
(Navbar as any).toHtml = (props: NavbarProps, _childrenHtml: string, nodeId?: string) => {
// Sanitized once here -- these are raw string-interpolation sinks below
// (hoverCol/bgColor go into a <style> block, the worst case: </style>
// breakout -> arbitrary <script>), see task-cssxss-brief.md.
const bgColor = cssValue(props.backgroundColor) || '#ffffff';
const textCol = cssValue(props.textColor) || '#3f3f46';
const hoverCol = cssValue(props.hoverColor) || '#3b82f6';
const ctaCol = cssValue(props.ctaColor) || '#3b82f6';
const ctaTextCol = cssValue(props.ctaTextColor) || '#ffffff';
const pad = cssValue(props.padding) || '16px 24px';
const alignment = props.navAlignment || 'space-between';
const sticky = props.isSticky;
const mobile = props.showMobileMenu;
const logoUrl = props.logoUrl || '/';
const links0 = props.links || defaultLinks;
// M-1: deterministic AND unique per-instance scope, keyed on the Craft
// node id. Two Navbars on the same page previously emitted an identical
// fixed id="navbar-links" (invalid duplicate-id HTML, ambiguous
// aria-controls target) and unscoped `.navbar-link:hover`/`.navbar-cta:hover`
// rules in each instance's own <style> block -- since both blocks target
// the SAME global selector, the later one in the DOM silently overrides
// the earlier one's hover color/behavior for BOTH navbars. Scoping the
// links-container id and adding a per-instance class on the <nav> root
// (used to prefix the hover selectors) eliminates both collisions.
const scope = scopeId(nodeId, JSON.stringify(links0) + alignment + pad, 'nav');
const linksId = `${scope}_links`;
const navStyle = cssPropsToString({
display: 'flex',
@@ -236,7 +265,7 @@ Navbar.craft = {
let logoHtml: string;
if (props.logoType === 'image' && props.logoImage) {
const imgStyle = cssPropsToString({ width: props.logoWidth || '120px', height: 'auto', display: 'block' });
logoHtml = `<a href="${escapeAttr(safeUrl(logoUrl))}" style="text-decoration:none;display:flex;align-items:center;flex-shrink:0"><img src="${escapeAttr(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 {
const logoStyle = cssPropsToString({
fontWeight: '700',
@@ -269,27 +298,30 @@ Navbar.craft = {
// via aria-expanded, kept in sync with the .navbar-open class by the
// inline onclick handler.
const hamburgerHtml = mobile
? `\n <button class="navbar-hamburger" aria-label="Toggle navigation menu" aria-expanded="false" aria-controls="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>
</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>
.navbar-link:hover { color: ${hoverCol} !important; }
.navbar-cta:hover { filter: brightness(1.1); }${mobile ? `
.${scope} .navbar-link:hover { color: ${hoverCol} !important; }
.${scope} .navbar-cta:hover { filter: brightness(1.1); }${mobile ? `
@media (max-width: 768px) {
.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); }
.navbar-links.navbar-open { display: flex !important; }
.${scope} .navbar-hamburger { display: flex !important; }
.${scope} .navbar-links { display: none !important; position: absolute; top: 100%; left: 0; right: 0; flex-direction: column !important; background-color: ${bgColor}; padding: 12px 24px; gap: 12px !important; box-shadow: 0 4px 12px rgba(0,0,0,0.1); }
.${scope} .navbar-links.navbar-open { display: flex !important; }
}` : ''}
</style>`;
// Add CSS class to each link for hover
const linksHtmlWithClass = links.map((link) => {
const target = link.isExternal ? ' target="_blank" rel="noopener noreferrer"' : '';
const downloadAttr = link.download ? ' download' : '';
const cls = link.isCta ? 'navbar-cta' : 'navbar-link';
const linkStyle = cssPropsToString({
textDecoration: 'none',
@@ -301,14 +333,14 @@ Navbar.craft = {
borderRadius: link.isCta ? '6px' : '0',
transition: 'color 0.15s, background-color 0.15s',
});
return `<a href="${escapeAttr(safeUrl(link.href || "#"))}" class="${cls}"${target}${linkStyle ? ` style="${linkStyle}"` : ''}>${escapeHtml(link.text)}</a>`;
return `<a href="${escapeAttr(safeUrl(link.href || "#"))}" class="${cls}"${target}${downloadAttr}${linkStyle ? ` style="${linkStyle}"` : ''}>${escapeHtml(link.text)}</a>`;
}).join('\n ');
return {
html: `${hoverCss}
<nav${navStyle ? ` style="${navStyle}${mobile ? ';position:relative' : ''}"` : ''}>
<nav class="${scope}"${navStyle ? ` style="${navStyle}${mobile ? ';position:relative' : ''}"` : ''}>
${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}
</div>
</nav>`,
@@ -11,3 +11,65 @@ describe('SearchBar.toHtml decorative icons (F2.5)', () => {
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)/);
});
});
// F2: SearchBar was purely decorative -- no action/method/input name, so
// submitting did nothing. It now emits a real GET form.
describe('SearchBar.toHtml is a functional GET search form (not decorative)', () => {
test('defaults to a GET form action="/" with the query input named "q"', () => {
const { html } = toHtml({}, '');
expect(html).toMatch(/<form role="search" action="\/" method="GET"/);
expect(html).toContain('<input type="search" name="q"');
});
test('a configured action (real search-results page) is used verbatim', () => {
const { html } = toHtml({ action: '/search' }, '');
expect(html).toContain('action="/search"');
});
test('a javascript: action is blocked via safeUrl and falls back to "/"', () => {
const { html } = toHtml({ action: 'javascript:alert(1)' }, '');
expect(html).toContain('action="/"');
expect(html).not.toContain('javascript:');
});
test('an empty/whitespace action falls back to "/"', () => {
const { html } = toHtml({ action: ' ' }, '');
expect(html).toContain('action="/"');
});
});
describe('SearchBar.toHtml box-model style passthrough', () => {
test('margin/border/box-shadow/opacity flow through via the style prop', () => {
const { html } = toHtml({ style: { marginBottom: '14px', border: '1px solid #aaa', boxShadow: '0 1px 4px rgba(0,0,0,.1)', opacity: '0.9' } }, '');
expect(html).toContain('margin-bottom:14px');
expect(html).toContain('border:1px solid #aaa');
expect(html).toContain('opacity:0.9');
});
});
describe('SearchBar.craft.props includes animation/visibility defaults', () => {
test('has blank/false defaults', () => {
expect(SearchBar.craft!.props).toMatchObject({
animation: '', animationDelay: '', hideOnDesktop: false, hideOnTablet: false, hideOnMobile: false,
});
});
});
+31 -3
View File
@@ -1,19 +1,29 @@
import React, { CSSProperties } from 'react';
import { useNode, UserComponent } from '@craftjs/core';
import { cssPropsToString } from '../../utils/style-helpers';
import { escapeHtml, escapeAttr } from '../../utils/escape';
import { escapeHtml, escapeAttr, safeUrl } from '../../utils/escape';
interface SearchBarProps {
placeholder?: string;
buttonText?: string;
showButton?: boolean;
/** Where the search GET request is submitted -- a real search-results page
* if the site has one, or '/' (site root) by default. The query is sent
* as `?q=...`, the conventional param name search-results pages look for. */
action?: string;
style?: CSSProperties;
animation?: string;
animationDelay?: string;
hideOnDesktop?: boolean;
hideOnTablet?: boolean;
hideOnMobile?: boolean;
}
export const SearchBar: UserComponent<SearchBarProps> = ({
placeholder = 'Search...',
buttonText = 'Search',
showButton = true,
action = '/',
style = {},
}) => {
const {
@@ -27,6 +37,8 @@ export const SearchBar: UserComponent<SearchBarProps> = ({
<form
ref={(ref: HTMLFormElement | null): void => { if (ref) connect(drag(ref)); }}
role="search"
action={action}
method="GET"
onSubmit={(e) => e.preventDefault()}
style={{
display: 'flex',
@@ -51,6 +63,7 @@ export const SearchBar: UserComponent<SearchBarProps> = ({
/>
<input
type="search"
name="q"
placeholder={placeholder}
style={{
width: '100%',
@@ -101,7 +114,13 @@ SearchBar.craft = {
placeholder: 'Search...',
buttonText: 'Search',
showButton: true,
action: '/',
style: {},
animation: '',
animationDelay: '',
hideOnDesktop: false,
hideOnTablet: false,
hideOnMobile: false,
},
rules: {
canDrag: () => true,
@@ -117,6 +136,7 @@ SearchBar.craft = {
placeholder = 'Search...',
buttonText = 'Search',
showButton = true,
action = '/',
style = {},
} = props;
@@ -133,11 +153,19 @@ SearchBar.craft = {
? `<button type="submit" style="padding:12px 20px;font-size:15px;font-weight:600;font-family:Inter,sans-serif;color:#ffffff;background-color:#3b82f6;border:none;border-radius:0 8px 8px 0;cursor:pointer;white-space:nowrap;display:flex;align-items:center;gap:6px"><i class="fa fa-search" style="font-size:13px" aria-hidden="true"></i>${escapeHtml(buttonText)}</button>`
: '';
// F2: previously a purely decorative <form> -- no action/method/input
// name at all, so submitting did nothing. A real GET to `action` with the
// query in the conventional `q` param makes this a functioning search
// form on publish (routes to a real search-results page if the site has
// one, or reloads '/' with ?q=... by default). `safeUrl` blocks
// javascript:/vbscript:/data:text/html breakout via the action attribute.
const actionAttr = escapeAttr(safeUrl(action) || '/');
return {
html: `<form role="search"${formStyle ? ` style="${formStyle}"` : ''}>
html: `<form role="search" action="${actionAttr}" method="GET"${formStyle ? ` style="${formStyle}"` : ''}>
<div style="position:relative;flex:1">
<i class="fa fa-search" style="position:absolute;left:14px;top:50%;transform:translateY(-50%);color:#9ca3af;font-size:14px;pointer-events:none" aria-hidden="true"></i>
<input type="search" placeholder="${escapeAttr(placeholder)}" style="${inputStyleStr}" />
<input type="search" name="q" placeholder="${escapeAttr(placeholder)}" style="${inputStyleStr}" />
</div>
${btnHtml}
</form>`,
@@ -14,3 +14,80 @@ describe('SocialLinks.toHtml accessibility (F2.5)', () => {
expect(html).toMatch(/<i class="fa fa-twitter"[^>]*aria-hidden="true"/);
});
});
describe('SocialLinks.toHtml XSS hardening (iconSize/iconColor/iconBgColor/gap into style=)', () => {
test('an iconSize value with an attribute-breakout string cannot escape style=""', () => {
const malicious = '20px" onmouseover="alert(1)';
const { html } = toHtml({ links: [{ platform: 'facebook', url: '#' }], iconSize: malicious as any }, '');
expect(html).not.toMatch(/"\s+onmouseover="/);
});
test('an iconColor value with an attribute-breakout string cannot escape style=""', () => {
const malicious = '#fff" onmouseover="alert(1)';
const { html } = toHtml({ links: [{ platform: 'facebook', url: '#' }], iconColor: malicious as any }, '');
expect(html).not.toMatch(/"\s+onmouseover="/);
});
test('an iconBgColor value with an attribute-breakout string cannot escape style=""', () => {
const malicious = '#374151" onmouseover="alert(1)';
const { html } = toHtml({ links: [{ platform: 'facebook', url: '#' }], iconShape: 'circle', iconBgColor: malicious as any }, '');
expect(html).not.toMatch(/"\s+onmouseover="/);
});
test('a gap value with an attribute-breakout string cannot escape the wrapper style=""', () => {
const malicious = '10px" onmouseover="alert(1)';
const { html } = toHtml({ links: [{ platform: 'facebook', url: '#' }], gap: malicious as any }, '');
expect(html).not.toMatch(/"\s+onmouseover="/);
});
test('a malicious platform key does not produce a raw class-attribute breakout', () => {
const malicious = 'x"><script>alert(1)</script>';
const { html } = toHtml({ links: [{ platform: malicious, url: '#' }] }, '');
expect(html).not.toContain('<script>alert(1)</script>');
});
test('a link url with a javascript: scheme is neutralized', () => {
const { html } = toHtml({ links: [{ platform: 'facebook', url: 'javascript:alert(1)' }] }, '');
expect(html).not.toContain('javascript:alert(1)');
});
});
describe('SocialLinks.toHtml iconShape/gap emission (previously built but unexposed in SocialStylePanel)', () => {
test('iconShape="circle" emits a 50% border-radius on the wrapping <a>', () => {
const { html } = toHtml({ links: [{ platform: 'facebook', url: '#' }], iconShape: 'circle' }, '');
expect(html).toMatch(/<a[^>]*style="[^"]*border-radius:50%[^"]*"/);
});
test('iconShape="square" emits border-radius:0', () => {
const { html } = toHtml({ links: [{ platform: 'facebook', url: '#' }], iconShape: 'square' }, '');
expect(html).toMatch(/<a[^>]*style="[^"]*border-radius:0[^"]*"/);
});
test('iconShape="rounded" emits a 6px border-radius', () => {
const { html } = toHtml({ links: [{ platform: 'facebook', url: '#' }], iconShape: 'rounded' }, '');
expect(html).toMatch(/<a[^>]*style="[^"]*border-radius:6px[^"]*"/);
});
test('iconShape="none" omits the background box entirely (transparent bg, no fixed box size)', () => {
const { html } = toHtml({ links: [{ platform: 'facebook', url: '#' }], iconShape: 'none' }, '');
const aTag = html.match(/<a[^>]*>/)![0];
expect(aTag).toContain('background-color:transparent');
expect(aTag).not.toContain('border-radius');
});
test('gap emits on the wrapper <div> style', () => {
const { html } = toHtml({ links: [{ platform: 'facebook', url: '#' }], gap: '24px' }, '');
expect(html).toMatch(/<div[^>]*style="[^"]*gap:24px[^"]*"/);
});
});
describe('SocialLinks.craft.props includes the box-model/animation/visibility rollout props', () => {
test('animation, animationDelay, and all 3 hideOn* flags are declared (blank/false defaults)', () => {
const props = (SocialLinks as any).craft.props;
expect(props).toHaveProperty('animation', '');
expect(props).toHaveProperty('animationDelay', '');
expect(props).toHaveProperty('hideOnDesktop', false);
expect(props).toHaveProperty('hideOnTablet', false);
expect(props).toHaveProperty('hideOnMobile', false);
});
});
+15 -4
View File
@@ -1,7 +1,7 @@
import React, { CSSProperties } from 'react';
import { useNode, UserComponent } from '@craftjs/core';
import { cssPropsToString } from '../../utils/style-helpers';
import { escapeAttr, safeUrl } from '../../utils/escape';
import { escapeAttr, safeUrl, cssValue } from '../../utils/escape';
interface SocialLink {
platform: string;
@@ -17,6 +17,11 @@ interface SocialLinksProps {
gap?: string;
alignment?: 'left' | 'center' | 'right';
style?: CSSProperties;
animation?: string;
animationDelay?: string;
hideOnDesktop?: boolean;
hideOnTablet?: boolean;
hideOnMobile?: boolean;
}
const platformIcons: Record<string, string> = {
@@ -151,6 +156,11 @@ SocialLinks.craft = {
gap: '10px',
alignment: 'center',
style: {},
animation: '',
animationDelay: '',
hideOnDesktop: false,
hideOnTablet: false,
hideOnMobile: false,
},
rules: {
canDrag: () => true,
@@ -163,9 +173,10 @@ SocialLinks.craft = {
(SocialLinks as any).toHtml = (props: SocialLinksProps, _childrenHtml: string) => {
const links = props.links || defaultLinks;
const iconSize = props.iconSize || '20px';
const iconColor = props.iconColor || '#ffffff';
const iconBgColor = props.iconBgColor || '#374151';
// Sanitized -- raw string-interpolation sinks in aStyle/getShapeStr below.
const iconSize = cssValue(props.iconSize) || '20px';
const iconColor = cssValue(props.iconColor) || '#ffffff';
const iconBgColor = cssValue(props.iconBgColor) || '#374151';
const iconShape = props.iconShape || 'circle';
const gap = props.gap || '10px';
const alignment = props.alignment || 'center';
@@ -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,68 @@ describe('StarRating.toHtml accessibility (F2.2)', () => {
expect(html).toContain('aria-label="Rating: 2 out of 10"');
});
});
describe('StarRating.toHtml XSS hardening (filledColor/emptyColor/size into style=)', () => {
test('a filledColor value containing a quote breakout is neutralized', () => {
const malicious = '#f00" onmouseover="alert(1)';
const { html } = toHtml({ rating: 3, maxStars: 5, filledColor: malicious }, '');
expect(html).not.toMatch(/style="[^"]*"[^>]*onmouseover/);
});
test('a size value containing </style><script> is neutralized', () => {
const malicious = '24px</style><script>alert(1)</script>';
const { html } = toHtml({ rating: 3, maxStars: 5, size: malicious }, '');
expect(html).not.toContain('<script>alert(1)</script>');
});
test('a normal filled color still renders', () => {
const { html } = toHtml({ rating: 5, maxStars: 5, filledColor: '#ff9900' }, '');
expect(html).toContain('color:#ff9900');
});
});
describe('StarRating.toHtml XSS hardening (rating/maxStars into aria-label, F2.2 CONFIRMED sink)', () => {
test('a maxStars value with an attribute-breakout string is neutralized in aria-label', () => {
const malicious = '5" onmouseover="alert(1)';
const { html } = toHtml({ rating: 3, maxStars: malicious as any }, '');
expect(html).not.toMatch(/onmouseover/);
expect(html).not.toMatch(/aria-label="Rating: 3 out of 5" onmouseover/);
});
test('a rating value with an attribute-breakout string is neutralized in aria-label', () => {
const malicious = '4.5" onmouseover="alert(1)';
const { html } = toHtml({ rating: malicious as any, maxStars: 5 }, '');
expect(html).not.toMatch(/onmouseover/);
});
test('a non-numeric maxStars does not blow up the star loop (no NaN glyph count, no huge output)', () => {
const malicious = '5" onmouseover="alert(1)';
const { html } = toHtml({ rating: 3, maxStars: malicious as any }, '');
const glyphs = html.match(/<i class="fa fa-star"/g) || [];
// Falls back to a sane default star count rather than looping 0 or NaN times.
expect(glyphs.length).toBeGreaterThan(0);
expect(glyphs.length).toBeLessThanOrEqual(50);
});
test('an absurdly large maxStars is clamped to a sane maximum instead of looping unboundedly', () => {
const { html } = toHtml({ rating: 3, maxStars: 1e9 as any }, '');
const glyphs = html.match(/<i class="fa fa-star"/g) || [];
expect(glyphs.length).toBeLessThanOrEqual(50);
});
test('normal numeric rating/maxStars still render the expected aria-label', () => {
const { html } = toHtml({ rating: 4.5, maxStars: 5 }, '');
expect(html).toMatch(/<span role="img" aria-label="Rating: 4\.5 out of 5"/);
});
});
describe('StarRating.craft.props includes the box-model/animation/visibility rollout props', () => {
test('animation, animationDelay, and all 3 hideOn* flags are declared (blank/false defaults)', () => {
const props = (StarRating as any).craft.props;
expect(props).toHaveProperty('animation', '');
expect(props).toHaveProperty('animationDelay', '');
expect(props).toHaveProperty('hideOnDesktop', false);
expect(props).toHaveProperty('hideOnTablet', false);
expect(props).toHaveProperty('hideOnMobile', false);
});
});
+34 -6
View File
@@ -1,6 +1,7 @@
import React, { CSSProperties } from 'react';
import { useNode, UserComponent } from '@craftjs/core';
import { cssPropsToString } from '../../utils/style-helpers';
import { cssValue, escapeAttr } from '../../utils/escape';
interface StarRatingProps {
rating?: number;
@@ -9,6 +10,11 @@ interface StarRatingProps {
filledColor?: string;
emptyColor?: string;
style?: CSSProperties;
animation?: string;
animationDelay?: string;
hideOnDesktop?: boolean;
hideOnTablet?: boolean;
hideOnMobile?: boolean;
}
export const StarRating: UserComponent<StarRatingProps> = ({
@@ -86,6 +92,11 @@ StarRating.craft = {
filledColor: '#f59e0b',
emptyColor: '#d1d5db',
style: {},
animation: '',
animationDelay: '',
hideOnDesktop: false,
hideOnTablet: false,
hideOnMobile: false,
},
rules: {
canDrag: () => true,
@@ -97,11 +108,23 @@ StarRating.craft = {
/* ---------- HTML export ---------- */
(StarRating as any).toHtml = (props: StarRatingProps, _childrenHtml: string) => {
const rating = props.rating ?? 4.5;
const maxStars = props.maxStars || 5;
const size = props.size || '24px';
const filledColor = props.filledColor || '#f59e0b';
const emptyColor = props.emptyColor || '#d1d5db';
// `rating`/`maxStars` are declared `number` in TS but arrive unchecked at
// runtime (AI update_props only validates node_id; deserialized saved
// state is untyped JSON) -- a string like `5" onmouseover="alert(1)`
// breaks out of the aria-label attribute below, and an uncoerced/unclamped
// maxStars can also blow up the star-glyph loop (NaN, absurd loop count,
// or -- observed -- a RangeError from string concatenation overflow with
// e.g. maxStars=1e9). Coerce to numbers with sane fallbacks/clamps first.
const ratingRaw = Number(props.rating);
const rating = Number.isFinite(ratingRaw) ? ratingRaw : 4.5;
const maxStarsRaw = Number(props.maxStars);
const maxStars = Number.isFinite(maxStarsRaw)
? Math.min(Math.max(Math.trunc(maxStarsRaw), 0), 50)
: 5;
// Sanitized -- raw string-interpolation sinks in the star glyphs below.
const size = cssValue(props.size) || '24px';
const filledColor = cssValue(props.filledColor) || '#f59e0b';
const emptyColor = cssValue(props.emptyColor) || '#d1d5db';
const wrapperStyle = cssPropsToString({
display: 'inline-flex',
alignItems: 'center',
@@ -123,7 +146,12 @@ StarRating.craft = {
// The star glyphs convey nothing to assistive tech on their own -- wrap
// in role="img" with a textual equivalent, and hide the decorative glyphs
// themselves (aria-hidden above) so AT doesn't announce each icon.
// Belt-and-suspenders: rating/maxStars are already coerced to numbers
// above, but the assembled label is still run through escapeAttr() in
// case a decimal/negative/Infinity edge case produces odd (though no
// longer dangerous) text.
const ariaLabel = escapeAttr(`Rating: ${rating} out of ${maxStars}`);
return {
html: `<span role="img" aria-label="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,67 @@
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>');
});
});
describe('TextBlock.toHtml typography depth (line-height/letter-spacing/transform/style/decoration)', () => {
test('line-height, letter-spacing, text-transform all flow into the style attribute', () => {
const { html } = toHtml({
text: 'x',
style: { lineHeight: '1.75', letterSpacing: '-0.02em', textTransform: 'capitalize' },
}, '');
expect(html).toContain('line-height:1.75');
expect(html).toContain('letter-spacing:-0.02em');
expect(html).toContain('text-transform:capitalize');
});
test('italic + underline toggles emit font-style and text-decoration', () => {
const { html } = toHtml({ text: 'x', style: { fontStyle: 'italic', textDecoration: 'underline' } }, '');
expect(html).toContain('font-style:italic');
expect(html).toContain('text-decoration:underline');
});
test('a custom font-size (not one of the presets) still flows through', () => {
const { html } = toHtml({ text: 'x', style: { fontSize: '19px' } }, '');
expect(html).toContain('font-size:19px');
});
});
describe('TextBlock.craft.props exposes the box-model + animation/visibility rollout', () => {
test('animation, animationDelay, hideOnDesktop/Tablet/Mobile are present with blank/false defaults', () => {
const props = (TextBlock as any).craft.props;
expect(props.animation).toBe('');
expect(props.animationDelay).toBe('0');
expect(props.hideOnDesktop).toBe(false);
expect(props.hideOnTablet).toBe(false);
expect(props.hideOnMobile).toBe(false);
});
test('style carries blank/default box-model + typography-depth keys', () => {
const style = (TextBlock as any).craft.props.style;
expect(style).toHaveProperty('marginTop');
expect(style).toHaveProperty('paddingTop');
expect(style).toHaveProperty('letterSpacing');
expect(style).toHaveProperty('textTransform');
expect(style.border).toBe('none');
expect(style.boxShadow).toBe('none');
expect(style.opacity).toBe('1');
});
});
+16 -1
View File
@@ -1,6 +1,7 @@
import React, { CSSProperties, useCallback, useRef, useEffect } from 'react';
import { useNode, UserComponent } from '@craftjs/core';
import { cssPropsToString } from '../../utils/style-helpers';
import { escapeHtml } from '../../utils/escape';
interface TextBlockProps {
text?: string;
@@ -82,7 +83,21 @@ TextBlock.craft = {
fontSize: '16px',
lineHeight: '1.6',
color: '#3f3f46',
letterSpacing: '',
textTransform: '' as CSSProperties['textTransform'],
fontStyle: '' as CSSProperties['fontStyle'],
textDecoration: '',
marginTop: '', marginRight: '', marginBottom: '', marginLeft: '',
paddingTop: '', paddingRight: '', paddingBottom: '', paddingLeft: '',
border: 'none',
boxShadow: 'none',
opacity: '1',
},
animation: '',
animationDelay: '0',
hideOnDesktop: false,
hideOnTablet: false,
hideOnMobile: false,
},
rules: {
canDrag: () => true,
@@ -95,6 +110,6 @@ TextBlock.craft = {
(TextBlock as any).toHtml = (props: TextBlockProps, _childrenHtml: string) => {
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>` };
};
@@ -0,0 +1,426 @@
<!-- ============================================================
HTML test fixture — everything below goes inside <body>
Unstyled on purpose. No external assets (SVG/data URIs only)
except the media/iframe block, which is intentionally broken
so you can see fallback behavior.
============================================================ -->
<a href="#main">Skip to content</a>
<header>
<h1>HTML Test Fixture</h1>
<p><small>A wide sample of elements for rendering, sanitizing, and parsing tests.</small></p>
<nav aria-label="Primary">
<ul>
<li><a href="#text">Text</a></li>
<li><a href="#lists">Lists</a></li>
<li><a href="#tables">Tables</a></li>
<li><a href="#forms">Forms</a></li>
<li><a href="#media">Media</a></li>
<li><a href="#edge">Edge cases</a></li>
</ul>
</nav>
</header>
<main id="main">
<!-- ========== HEADINGS ========== -->
<section id="headings">
<h2>Headings</h2>
<h1>Heading level 1</h1>
<h2>Heading level 2</h2>
<h3>Heading level 3</h3>
<h4>Heading level 4</h4>
<h5>Heading level 5</h5>
<h6>Heading level 6</h6>
<hgroup>
<h2>Grouped heading</h2>
<p>Subtitle paragraph inside hgroup</p>
</hgroup>
</section>
<hr>
<!-- ========== TEXT & INLINE ========== -->
<section id="text">
<h2>Text and inline elements</h2>
<p>A normal paragraph with a fair amount of text so you can check line height, wrapping, and measure. It runs long enough to break across several lines in most containers, which is the whole point of including it here at all.</p>
<p>
<strong>strong</strong>, <b>b</b>, <em>em</em>, <i>i</i>, <u>u</u>,
<s>s</s>, <del>del</del>, <ins>ins</ins>, <mark>mark</mark>,
<small>small</small>, H<sub>2</sub>O, x<sup>2</sup>,
<code>inline code</code>, <kbd>Ctrl</kbd>+<kbd>C</kbd>,
<samp>output text</samp>, <var>variable</var>,
<abbr title="HyperText Markup Language">HTML</abbr>,
<dfn>definition term</dfn>,
<time datetime="2026-08-09">August 9, 2026</time>,
<data value="42">forty-two</data>,
<q>short inline quote</q>,
<cite>Cited Work</cite>,
<bdi>إسم</bdi>,
<bdo dir="rtl">reversed direction</bdo>,
<ruby>漢<rt>kan</rt>字<rt>ji</rt></ruby>
</p>
<p>
Links:
<a href="#top">internal anchor</a> ·
<a href="https://example.com">absolute</a> ·
<a href="/relative/path">relative</a> ·
<a href="mailto:test@example.com">mailto</a> ·
<a href="tel:+15555550123">tel</a> ·
<a href="https://example.com" target="_blank" rel="noopener noreferrer">new tab</a> ·
<a href="#" download>download attr</a>
</p>
<blockquote cite="https://example.com/source">
<p>A block quotation. It contains its own paragraph and a nested quote so you can check indentation stacking.</p>
<blockquote><p>Nested block quotation.</p></blockquote>
<footer>— <cite>Someone, Somewhere</cite></footer>
</blockquote>
<pre><code>#!/usr/bin/env bash
set -euo pipefail
for i in {1..3}; do
printf 'iteration %d\n' "$i"
done
# a deliberately long line to force horizontal overflow: aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
</code></pre>
<p>Line break here,<br>after the break.</p>
<p>Word break opportunity: super<wbr>cali<wbr>fragilistic<wbr>expiali<wbr>docious</p>
<address>
Contact: <a href="mailto:admin@example.com">admin@example.com</a><br>
123 Nowhere St, Somewhere
</address>
<p>Entities: &amp; &lt; &gt; &quot; &apos; &copy; &reg; &trade; &nbsp; &mdash; &hellip; &#8364; &#x1F600;</p>
</section>
<hr>
<!-- ========== LISTS ========== -->
<section id="lists">
<h2>Lists</h2>
<h3>Unordered, nested</h3>
<ul>
<li>First item</li>
<li>Second item
<ul>
<li>Nested item
<ul><li>Deeply nested item</li></ul>
</li>
<li>Another nested item</li>
</ul>
</li>
<li>Third item with a longer body of text so that it wraps onto more than one line and you can confirm the hanging indent behaves.</li>
</ul>
<h3>Ordered variants</h3>
<ol>
<li>Default numbering</li>
<li>Second
<ol type="a"><li>Lower alpha</li><li>Second alpha</li></ol>
</li>
</ol>
<ol start="5" reversed>
<li>Reversed, starting at 5</li>
<li>Next</li>
<li>Next</li>
</ol>
<h3>Description list</h3>
<dl>
<dt>Term one</dt>
<dd>Definition of the first term.</dd>
<dt>Term two</dt>
<dt>Term two, alias</dt>
<dd>Definition covering both terms above.</dd>
</dl>
<h3>Menu</h3>
<menu>
<li><button type="button">Copy</button></li>
<li><button type="button">Paste</button></li>
</menu>
</section>
<hr>
<!-- ========== TABLES ========== -->
<section id="tables">
<h2>Tables</h2>
<table>
<caption>Quarterly figures with spans and a footer</caption>
<colgroup>
<col span="1">
<col span="2">
<col>
</colgroup>
<thead>
<tr>
<th scope="col">Region</th>
<th scope="col">Q1</th>
<th scope="col">Q2</th>
<th scope="col">Notes</th>
</tr>
</thead>
<tbody>
<tr>
<th scope="row">North</th>
<td>1,204</td>
<td>1,391</td>
<td rowspan="2">Shared note spanning two rows</td>
</tr>
<tr>
<th scope="row">South</th>
<td>988</td>
<td>1,022</td>
</tr>
<tr>
<th scope="row">East</th>
<td colspan="2">Merged across two quarters</td>
<td>—</td>
</tr>
</tbody>
<tfoot>
<tr>
<th scope="row">Total</th>
<td>2,192</td>
<td>2,413</td>
<td></td>
</tr>
</tfoot>
</table>
<h3>Wide table (horizontal overflow)</h3>
<table>
<tr><th>A</th><th>B</th><th>C</th><th>D</th><th>E</th><th>F</th><th>G</th><th>H</th><th>I</th><th>J</th><th>K</th><th>L</th></tr>
<tr><td>value-1</td><td>value-2</td><td>value-3</td><td>value-4</td><td>value-5</td><td>value-6</td><td>value-7</td><td>value-8</td><td>value-9</td><td>value-10</td><td>value-11</td><td>value-12</td></tr>
</table>
</section>
<hr>
<!-- ========== FORMS ========== -->
<section id="forms">
<h2>Forms</h2>
<form action="#" method="get">
<fieldset>
<legend>Text inputs</legend>
<p><label for="f-text">Text</label> <input id="f-text" name="text" type="text" placeholder="Placeholder" value="Prefilled"></p>
<p><label for="f-search">Search</label> <input id="f-search" type="search" list="suggestions"></p>
<datalist id="suggestions">
<option value="alpha"></option>
<option value="beta"></option>
<option value="gamma"></option>
</datalist>
<p><label for="f-email">Email</label> <input id="f-email" type="email" required></p>
<p><label for="f-url">URL</label> <input id="f-url" type="url"></p>
<p><label for="f-tel">Tel</label> <input id="f-tel" type="tel" pattern="[0-9-+ ]+"></p>
<p><label for="f-pass">Password</label> <input id="f-pass" type="password" minlength="8"></p>
<p><label for="f-num">Number</label> <input id="f-num" type="number" min="0" max="100" step="5" value="25"></p>
<p><label for="f-area">Textarea</label><br><textarea id="f-area" rows="4" cols="40">Multiline
content
here</textarea></p>
<p><label for="f-ro">Readonly</label> <input id="f-ro" type="text" value="read only" readonly></p>
<p><label for="f-dis">Disabled</label> <input id="f-dis" type="text" value="disabled" disabled></p>
</fieldset>
<fieldset>
<legend>Date, time, color, range, file</legend>
<p><label for="f-date">Date</label> <input id="f-date" type="date" value="2026-08-09"></p>
<p><label for="f-time">Time</label> <input id="f-time" type="time" value="13:45"></p>
<p><label for="f-dtl">Datetime-local</label> <input id="f-dtl" type="datetime-local"></p>
<p><label for="f-month">Month</label> <input id="f-month" type="month"></p>
<p><label for="f-week">Week</label> <input id="f-week" type="week"></p>
<p><label for="f-color">Color</label> <input id="f-color" type="color" value="#336699"></p>
<p><label for="f-range">Range</label> <input id="f-range" type="range" min="0" max="10" value="7"></p>
<p><label for="f-file">File</label> <input id="f-file" type="file" multiple accept=".txt,.md"></p>
</fieldset>
<fieldset>
<legend>Choices</legend>
<p>
<label><input type="checkbox" name="c" value="1" checked> Checked</label>
<label><input type="checkbox" name="c" value="2"> Unchecked</label>
<label><input type="checkbox" name="c" value="3" disabled> Disabled</label>
</p>
<p>
<label><input type="radio" name="r" value="a" checked> Option A</label>
<label><input type="radio" name="r" value="b"> Option B</label>
</p>
<p>
<label for="f-select">Select</label>
<select id="f-select" name="select">
<option value="">— choose —</option>
<optgroup label="Group one">
<option value="1" selected>One</option>
<option value="2">Two</option>
</optgroup>
<optgroup label="Group two" disabled>
<option value="3">Three</option>
</optgroup>
</select>
</p>
<p>
<label for="f-multi">Multi-select</label><br>
<select id="f-multi" multiple size="4">
<option>Red</option><option selected>Green</option><option>Blue</option><option>Violet</option>
</select>
</p>
</fieldset>
<fieldset>
<legend>Output and buttons</legend>
<p><label for="f-prog">Progress</label> <progress id="f-prog" value="0.6">60%</progress></p>
<p><label for="f-meter">Meter</label> <meter id="f-meter" min="0" max="100" low="30" high="80" optimum="90" value="72">72</meter></p>
<p><output name="result" for="f-num f-range">Computed output</output></p>
<p>
<button type="submit">Submit</button>
<button type="reset">Reset</button>
<button type="button">Plain button</button>
<button type="button" disabled>Disabled button</button>
<input type="submit" value="Input submit">
<input type="button" value="Input button">
</p>
<input type="hidden" name="csrf" value="hidden-value">
</fieldset>
</form>
</section>
<hr>
<!-- ========== MEDIA & EMBEDS ========== -->
<section id="media">
<h2>Media and embeds</h2>
<h3>Inline SVG</h3>
<svg width="180" height="90" viewBox="0 0 180 90" role="img" aria-label="Two shapes">
<rect x="5" y="5" width="80" height="80" fill="none" stroke="currentColor" stroke-width="3"></rect>
<circle cx="135" cy="45" r="40" fill="none" stroke="currentColor" stroke-width="3"></circle>
<text x="45" y="50" text-anchor="middle" font-size="14" fill="currentColor">svg</text>
</svg>
<h3>Figure with data-URI image</h3>
<figure>
<img alt="Small red square"
width="64" height="64"
src="data:image/svg+xml;utf8,%3Csvg%20xmlns%3D'http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg'%20width%3D'64'%20height%3D'64'%3E%3Crect%20width%3D'64'%20height%3D'64'%20fill%3D'%23c0392b'%2F%3E%3C%2Fsvg%3E">
<figcaption>Figure caption describing the image above.</figcaption>
</figure>
<h3>Broken image (alt-text fallback test)</h3>
<img src="does-not-exist.png" alt="This alt text should render because the source is missing" width="200" height="100">
<h3>Picture element</h3>
<picture>
<source media="(min-width: 800px)" srcset="wide.png">
<source media="(min-width: 400px)" srcset="medium.png">
<img src="narrow.png" alt="Responsive image fallback" width="150" height="80">
</picture>
<h3>Video and audio (sources intentionally missing)</h3>
<video controls width="320" poster="poster.jpg">
<source src="clip.webm" type="video/webm">
<source src="clip.mp4" type="video/mp4">
<track kind="captions" src="captions.vtt" srclang="en" label="English">
Your browser does not support the video element.
</video>
<audio controls>
<source src="tone.ogg" type="audio/ogg">
<source src="tone.mp3" type="audio/mpeg">
Your browser does not support the audio element.
</audio>
<h3>Canvas and iframe</h3>
<canvas width="200" height="60">Canvas fallback text</canvas>
<iframe title="Sandboxed iframe" src="about:blank" width="300" height="120" sandbox loading="lazy"></iframe>
</section>
<hr>
<!-- ========== INTERACTIVE / SEMANTIC ========== -->
<section id="interactive">
<h2>Interactive and semantic containers</h2>
<details>
<summary>Collapsed disclosure</summary>
<p>Hidden content revealed on toggle.</p>
</details>
<details open>
<summary>Open disclosure</summary>
<ul><li>With a list inside</li><li>Second item</li></ul>
</details>
<dialog id="test-dialog">
<p>Non-modal dialog content.</p>
<button type="button" onclick="this.closest('dialog').close()">Close</button>
</dialog>
<button type="button" onclick="document.getElementById('test-dialog').show()">Open dialog</button>
<article>
<header><h3>Article header</h3></header>
<p>Article body content.</p>
<aside><p>An aside nested inside the article.</p></aside>
<footer><p>Article footer.</p></footer>
</article>
<p><span contenteditable="true">Editable inline region</span></p>
<p hidden>This paragraph has the hidden attribute and should not render.</p>
<template id="tpl">
<p>Template content — must not render until cloned.</p>
</template>
</section>
<hr>
<!-- ========== EDGE CASES ========== -->
<section id="edge">
<h2>Edge cases</h2>
<p>Very long unbroken token (overflow test):</p>
<p>aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa</p>
<p>Long URL: https://example.com/a/very/long/path/segment/that/keeps/going/and/going?query=1&amp;another=2&amp;third=3#fragment-identifier</p>
<p lang="ar" dir="rtl">هذا نص عربي لاختبار الاتجاه من اليمين إلى اليسار.</p>
<p lang="he" dir="rtl">זהו טקסט עברי לבדיקה.</p>
<p lang="ja">日本語のテキストです。改行と折り返しの確認用。</p>
<p lang="de">Straßenverkehrsordnung — Grüße aus München</p>
<p>Emoji &amp; combining: 👋🏽 👨‍👩‍👧‍👦 🇺🇸 é vs é (precomposed vs combining)</p>
<p>Zero-width chars between letters: a&#8203;b&#8203;c</p>
<p>Escaped tag text: &lt;script&gt;alert(1)&lt;/script&gt;</p>
<p>Attribute with quotes: <span title='He said "hello"'>hover me</span></p>
<p>Empty elements follow:</p>
<div></div>
<p></p>
<ul></ul>
<table></table>
<p>Deep nesting:</p>
<div><div><div><div><div><div><div><p>Seven levels deep.</p></div></div></div></div></div></div></div>
<p>Inline element stress:
<strong><em><u><s><mark>all five at once</mark></s></u></em></strong>
</p>
<p style="color: teal;">Inline style attribute (teal).</p>
<p class="custom-class another-class" data-test-id="edge-1" data-value="42">Element with classes and data attributes.</p>
</section>
</main>
<footer>
<p><small>End of fixture — <time datetime="2026-08-09">2026-08-09</time></small></p>
</footer>
@@ -0,0 +1,415 @@
<a href="#main">Skip to content</a>
<header>
<h1>HTML Test Fixture</h1>
<p><small>A wide sample of elements for rendering, sanitizing, and parsing tests.</small></p>
<nav aria-label="Primary">
<ul>
<li><a href="#text">Text</a></li>
<li><a href="#lists">Lists</a></li>
<li><a href="#tables">Tables</a></li>
<li><a href="#forms">Forms</a></li>
<li><a href="#media">Media</a></li>
<li><a href="#edge">Edge cases</a></li>
</ul>
</nav>
</header>
<main id="main">
<section id="headings">
<h2>Headings</h2>
<h1>Heading level 1</h1>
<h2>Heading level 2</h2>
<h3>Heading level 3</h3>
<h4>Heading level 4</h4>
<h5>Heading level 5</h5>
<h6>Heading level 6</h6>
<hgroup>
<h2>Grouped heading</h2>
<p>Subtitle paragraph inside hgroup</p>
</hgroup>
</section>
<hr>
<section id="text">
<h2>Text and inline elements</h2>
<p>A normal paragraph with a fair amount of text so you can check line height, wrapping, and measure. It runs long enough to break across several lines in most containers, which is the whole point of including it here at all.</p>
<p>
<strong>strong</strong>, <b>b</b>, <em>em</em>, <i>i</i>, <u>u</u>,
<s>s</s>, <del>del</del>, <ins>ins</ins>, <mark>mark</mark>,
<small>small</small>, H<sub>2</sub>O, x<sup>2</sup>,
<code>inline code</code>, <kbd>Ctrl</kbd>+<kbd>C</kbd>,
<samp>output text</samp>, <var>variable</var>,
<abbr title="HyperText Markup Language">HTML</abbr>,
<dfn>definition term</dfn>,
<time datetime="2026-08-09">August 9, 2026</time>,
<data value="42">forty-two</data>,
<q>short inline quote</q>,
<cite>Cited Work</cite>,
<bdi>إسم</bdi>,
<bdo dir="rtl">reversed direction</bdo>,
<ruby>漢<rt>kan</rt>字<rt>ji</rt></ruby>
</p>
<p>
Links:
<a href="#top">internal anchor</a> ·
<a href="https://example.com">absolute</a> ·
<a href="/relative/path">relative</a> ·
<a href="mailto:test@example.com">mailto</a> ·
<a href="tel:+15555550123">tel</a> ·
<a href="https://example.com" target="_blank" rel="noopener noreferrer">new tab</a> ·
<a href="#" download="">download attr</a>
</p>
<blockquote cite="https://example.com/source">
<p>A block quotation. It contains its own paragraph and a nested quote so you can check indentation stacking.</p>
<blockquote><p>Nested block quotation.</p></blockquote>
<footer>— <cite>Someone, Somewhere</cite></footer>
</blockquote>
<pre><code>#!/usr/bin/env bash
set -euo pipefail
for i in {1..3}; do
printf 'iteration %d\n' "$i"
done
# a deliberately long line to force horizontal overflow: aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
</code></pre>
<p>Line break here,<br>after the break.</p>
<p>Word break opportunity: super<wbr>cali<wbr>fragilistic<wbr>expiali<wbr>docious</p>
<address>
Contact: <a href="mailto:admin@example.com">admin@example.com</a><br>
123 Nowhere St, Somewhere
</address>
<p>Entities: &amp; &lt; &gt; " ' © ® ™ &nbsp; — … € 😀</p>
</section>
<hr>
<section id="lists">
<h2>Lists</h2>
<h3>Unordered, nested</h3>
<ul>
<li>First item</li>
<li>Second item
<ul>
<li>Nested item
<ul><li>Deeply nested item</li></ul>
</li>
<li>Another nested item</li>
</ul>
</li>
<li>Third item with a longer body of text so that it wraps onto more than one line and you can confirm the hanging indent behaves.</li>
</ul>
<h3>Ordered variants</h3>
<ol>
<li>Default numbering</li>
<li>Second
<ol type="a"><li>Lower alpha</li><li>Second alpha</li></ol>
</li>
</ol>
<ol start="5" reversed="">
<li>Reversed, starting at 5</li>
<li>Next</li>
<li>Next</li>
</ol>
<h3>Description list</h3>
<dl>
<dt>Term one</dt>
<dd>Definition of the first term.</dd>
<dt>Term two</dt>
<dt>Term two, alias</dt>
<dd>Definition covering both terms above.</dd>
</dl>
<h3>Menu</h3>
<menu>
<li><button type="button">Copy</button></li>
<li><button type="button">Paste</button></li>
</menu>
</section>
<hr>
<section id="tables">
<h2>Tables</h2>
<table>
<caption>Quarterly figures with spans and a footer</caption>
<colgroup>
<col span="1">
<col span="2">
<col>
</colgroup>
<thead>
<tr>
<th scope="col">Region</th>
<th scope="col">Q1</th>
<th scope="col">Q2</th>
<th scope="col">Notes</th>
</tr>
</thead>
<tbody>
<tr>
<th scope="row">North</th>
<td>1,204</td>
<td>1,391</td>
<td rowspan="2">Shared note spanning two rows</td>
</tr>
<tr>
<th scope="row">South</th>
<td>988</td>
<td>1,022</td>
</tr>
<tr>
<th scope="row">East</th>
<td colspan="2">Merged across two quarters</td>
<td>—</td>
</tr>
</tbody>
<tfoot>
<tr>
<th scope="row">Total</th>
<td>2,192</td>
<td>2,413</td>
<td></td>
</tr>
</tfoot>
</table>
<h3>Wide table (horizontal overflow)</h3>
<table>
<tbody><tr><th>A</th><th>B</th><th>C</th><th>D</th><th>E</th><th>F</th><th>G</th><th>H</th><th>I</th><th>J</th><th>K</th><th>L</th></tr>
<tr><td>value-1</td><td>value-2</td><td>value-3</td><td>value-4</td><td>value-5</td><td>value-6</td><td>value-7</td><td>value-8</td><td>value-9</td><td>value-10</td><td>value-11</td><td>value-12</td></tr>
</tbody></table>
</section>
<hr>
<section>
<h2>Forms</h2>
<form action="#" method="get">
<fieldset>
<legend>Text inputs</legend>
<p><label for="f-text">Text</label> <input id="f-text" name="text" type="text" placeholder="Placeholder" value="Prefilled"></p>
<p><label for="f-search">Search</label> <input id="f-search" type="search" list="suggestions"></p>
<datalist id="suggestions">
<option value="alpha"></option>
<option value="beta"></option>
<option value="gamma"></option>
</datalist>
<p><label for="f-email">Email</label> <input id="f-email" type="email" required=""></p>
<p><label for="f-url">URL</label> <input id="f-url" type="url"></p>
<p><label for="f-tel">Tel</label> <input id="f-tel" type="tel" pattern="[0-9-+ ]+"></p>
<p><label for="f-pass">Password</label> <input id="f-pass" type="password" minlength="8"></p>
<p><label for="f-num">Number</label> <input id="f-num" type="number" min="0" max="100" step="5" value="25"></p>
<p><label for="f-area">Textarea</label><br><textarea id="f-area" rows="4" cols="40">Multiline
content
here</textarea></p>
<p><label for="f-ro">Readonly</label> <input id="f-ro" type="text" value="read only" readonly=""></p>
<p><label for="f-dis">Disabled</label> <input id="f-dis" type="text" value="disabled" disabled=""></p>
</fieldset>
<fieldset>
<legend>Date, time, color, range, file</legend>
<p><label for="f-date">Date</label> <input id="f-date" type="date" value="2026-08-09"></p>
<p><label for="f-time">Time</label> <input id="f-time" type="time" value="13:45"></p>
<p><label for="f-dtl">Datetime-local</label> <input id="f-dtl" type="datetime-local"></p>
<p><label for="f-month">Month</label> <input id="f-month" type="month"></p>
<p><label for="f-week">Week</label> <input id="f-week" type="week"></p>
<p><label for="f-color">Color</label> <input id="f-color" type="color" value="#336699"></p>
<p><label for="f-range">Range</label> <input id="f-range" type="range" min="0" max="10" value="7"></p>
<p><label for="f-file">File</label> <input id="f-file" type="file" multiple="" accept=".txt,.md"></p>
</fieldset>
<fieldset>
<legend>Choices</legend>
<p>
<label><input type="checkbox" name="c" value="1" checked=""> Checked</label>
<label><input type="checkbox" name="c" value="2"> Unchecked</label>
<label><input type="checkbox" name="c" value="3" disabled=""> Disabled</label>
</p>
<p>
<label><input type="radio" name="r" value="a" checked=""> Option A</label>
<label><input type="radio" name="r" value="b"> Option B</label>
</p>
<p>
<label for="f-select">Select</label>
<select id="f-select" name="select">
<option value="">— choose —</option>
<optgroup label="Group one">
<option value="1" selected="">One</option>
<option value="2">Two</option>
</optgroup>
<optgroup label="Group two" disabled="">
<option value="3">Three</option>
</optgroup>
</select>
</p>
<p>
<label for="f-multi">Multi-select</label><br>
<select id="f-multi" multiple="" size="4">
<option>Red</option><option selected="">Green</option><option>Blue</option><option>Violet</option>
</select>
</p>
</fieldset>
<fieldset>
<legend>Output and buttons</legend>
<p><label for="f-prog">Progress</label> <progress id="f-prog" value="0.6">60%</progress></p>
<p><label for="f-meter">Meter</label> <meter id="f-meter" min="0" max="100" low="30" high="80" optimum="90" value="72">72</meter></p>
<p><output name="result" for="f-num f-range">Computed output</output></p>
<p>
<button type="submit">Submit</button>
<button type="reset">Reset</button>
<button type="button">Plain button</button>
<button type="button" disabled="">Disabled button</button>
<input type="submit" value="Input submit">
<input type="button" value="Input button">
</p>
<input type="hidden" name="csrf" value="hidden-value">
</fieldset>
</form>
</section>
<hr>
<section id="media">
<h2>Media and embeds</h2>
<h3>Inline SVG</h3>
<svg width="180" height="90" viewBox="0 0 180 90" role="img" aria-label="Two shapes">
<rect x="5" y="5" width="80" height="80" fill="none" stroke="currentColor" stroke-width="3"></rect>
<circle cx="135" cy="45" r="40" fill="none" stroke="currentColor" stroke-width="3"></circle>
<text x="45" y="50" text-anchor="middle" font-size="14" fill="currentColor">svg</text>
</svg>
<h3>Figure with data-URI image</h3>
<figure>
<img alt="Small red square" width="64" height="64" src="data:image/svg+xml;utf8,%3Csvg%20xmlns%3D'http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg'%20width%3D'64'%20height%3D'64'%3E%3Crect%20width%3D'64'%20height%3D'64'%20fill%3D'%23c0392b'%2F%3E%3C%2Fsvg%3E">
<figcaption>Figure caption describing the image above.</figcaption>
</figure>
<h3>Broken image (alt-text fallback test)</h3>
<img src="does-not-exist.png" alt="This alt text should render because the source is missing" width="200" height="100">
<h3>Picture element</h3>
<picture>
<source media="(min-width: 800px)" srcset="wide.png">
<source media="(min-width: 400px)" srcset="medium.png">
<img src="narrow.png" alt="Responsive image fallback" width="150" height="80">
</picture>
<h3>Video and audio (sources intentionally missing)</h3>
<video controls="" width="320" poster="poster.jpg">
<source src="clip.webm" type="video/webm">
<source src="clip.mp4" type="video/mp4">
<track kind="captions" src="captions.vtt" srclang="en" label="English">
Your browser does not support the video element.
</video>
<audio controls="">
<source src="tone.ogg" type="audio/ogg">
<source src="tone.mp3" type="audio/mpeg">
Your browser does not support the audio element.
</audio>
<h3>Canvas and iframe</h3>
<canvas width="200" height="60">Canvas fallback text</canvas>
<iframe title="Sandboxed iframe" width="300" height="120" sandbox="allow-scripts allow-same-origin allow-popups allow-forms" loading="lazy" referrerpolicy="no-referrer"></iframe>
</section>
<hr>
<section id="interactive">
<h2>Interactive and semantic containers</h2>
<details>
<summary>Collapsed disclosure</summary>
<p>Hidden content revealed on toggle.</p>
</details>
<details open="">
<summary>Open disclosure</summary>
<ul><li>With a list inside</li><li>Second item</li></ul>
</details>
<p>Non-modal dialog content.</p>
<button type="button">Close</button>
<button type="button">Open dialog</button>
<article>
<header><h3>Article header</h3></header>
<p>Article body content.</p>
<aside><p>An aside nested inside the article.</p></aside>
<footer><p>Article footer.</p></footer>
</article>
<p><span contenteditable="true">Editable inline region</span></p>
<p hidden="">This paragraph has the hidden attribute and should not render.</p>
</section>
<hr>
<section id="edge">
<h2>Edge cases</h2>
<p>Very long unbroken token (overflow test):</p>
<p>aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa</p>
<p>Long URL: https://example.com/a/very/long/path/segment/that/keeps/going/and/going?query=1&amp;another=2&amp;third=3#fragment-identifier</p>
<p lang="ar" dir="rtl">هذا نص عربي لاختبار الاتجاه من اليمين إلى اليسار.</p>
<p lang="he" dir="rtl">זהו טקסט עברי לבדיקה.</p>
<p lang="ja">日本語のテキストです。改行と折り返しの確認用。</p>
<p lang="de">Straßenverkehrsordnung — Grüße aus München</p>
<p>Emoji &amp; combining: 👋🏽 👨‍👩‍👧‍👦 🇺🇸 é vs é (precomposed vs combining)</p>
<p>Zero-width chars between letters: a​b​c</p>
<p>Escaped tag text: &lt;script&gt;alert(1)&lt;/script&gt;</p>
<p>Attribute with quotes: <span title="He said &quot;hello&quot;">hover me</span></p>
<p>Empty elements follow:</p>
<div></div>
<p></p>
<ul></ul>
<table></table>
<p>Deep nesting:</p>
<div><div><div><div><div><div><div><p>Seven levels deep.</p></div></div></div></div></div></div></div>
<p>Inline element stress:
<strong><em><u><s><mark>all five at once</mark></s></u></em></strong>
</p>
<p style="color: teal;">Inline style attribute (teal).</p>
<p class="custom-class another-class" data-test-id="edge-1" data-value="42">Element with classes and data attributes.</p>
</section>
</main>
<footer>
<p><small>End of fixture — <time datetime="2026-08-09">2026-08-09</time></small></p>
</footer>
@@ -110,3 +110,179 @@ describe('ContactForm.toHtml accessibility (F2.1)', () => {
expect(ids1).toEqual(ids2);
});
});
describe('ContactForm.toHtml field type attribute sanitization', () => {
test('malicious field.type cannot break out of the input attribute; falls back to type="text"', () => {
const fields = [{ type: 'text"><img src=x onerror=alert(1)>' as any, label: 'Name', name: 'name', placeholder: 'Your name', required: false }];
const { html } = toHtml({ fields }, '');
expect(html).not.toContain('<img');
expect(html).not.toContain('onerror=');
expect(html).toContain('type="text"');
});
test('legitimate email field type still passes through unchanged', () => {
const fields = [{ type: 'email' as const, label: 'Email', name: 'email', placeholder: 'you@example.com', required: false }];
const { html } = toHtml({ fields }, '');
expect(html).toContain('type="email"');
});
});
// F1: the field editor (FormStylePanel) can now create fields of every type
// in sanitizeInputType's allowlist, plus textarea/select. Verify each
// renders with the right control, label/for association, and required flag.
describe('ContactForm.toHtml renders every configured field type/label/required', () => {
const cases: { type: string; tag: string }[] = [
{ type: 'text', tag: 'input' },
{ type: 'email', tag: 'input' },
{ type: 'tel', tag: 'input' },
{ type: 'number', tag: 'input' },
{ type: 'password', tag: 'input' },
{ type: 'url', tag: 'input' },
{ type: 'search', tag: 'input' },
{ type: 'date', tag: 'input' },
{ type: 'checkbox', tag: 'input' },
{ type: 'radio', tag: 'input' },
];
test.each(cases)('type=$type renders a sanitized <$tag type="$type"> with label + for/id wiring', ({ type, tag }) => {
const fields = [{ type: type as any, label: `Field ${type}`, name: `f_${type}`, placeholder: '', required: true }];
const { html } = toHtml({ fields }, '');
expect(html).toContain(`<${tag}`);
expect(html).toContain(`type="${type}"`);
expect(html).toContain(`Field ${type}`);
// required renders the input attribute AND the visual asterisk
expect(html).toMatch(/ required/);
expect(html).toContain('*</span>');
const labelFor = html.match(/<label for="([^"]+)"/)![1];
expect(html).toContain(`id="${labelFor}"`);
});
test('type=textarea renders a <textarea>, not an <input>', () => {
const fields = [{ type: 'textarea' as const, label: 'Message', name: 'message', placeholder: '', required: false }];
const { html } = toHtml({ fields }, '');
expect(html).toMatch(/<textarea[^>]*name="message"/);
expect(html).not.toMatch(/<input[^>]*name="message"/);
});
test('type=select renders a <select> with escaped <option> values from field.options', () => {
const fields = [{ type: 'select' as const, label: 'Plan', name: 'plan', placeholder: 'Choose one', required: false, options: ['Basic', 'Pro', '"><script>alert(1)</script>'] }];
const { html } = toHtml({ fields }, '');
expect(html).toMatch(/<select[^>]*name="plan"/);
expect(html).toContain('<option value="Basic">Basic</option>');
expect(html).toContain('<option value="Pro">Pro</option>');
expect(html).not.toContain('<script>alert(1)</script>');
});
test('a non-required field omits both the required attribute and the asterisk', () => {
const fields = [{ type: 'text' as const, label: 'Nickname', name: 'nickname', placeholder: '', required: false }];
const { html } = toHtml({ fields }, '');
expect(html).not.toMatch(/ required/);
expect(html).not.toContain('*</span>');
});
});
// Box-model / animation / visibility rollout (common enh-batch pattern):
// these are top-level props consumed generically by the export's
// buildDataAttrs() -- this just confirms the defaults are present on
// craft.props so the panel controls render and the props survive save/load.
describe('ContactForm.craft.props includes animation/visibility defaults', () => {
test('has blank/false defaults for animation, animationDelay, hideOnDesktop/Tablet/Mobile', () => {
expect(ContactForm.craft!.props).toMatchObject({
animation: '',
animationDelay: '',
hideOnDesktop: false,
hideOnTablet: false,
hideOnMobile: false,
});
});
});
/* ---------- Webhook destination (Task 10) ----------
This file builds props inline rather than spreading a shared object, so
`defaultProps` is introduced here for the destination cases only; every
pre-existing test above is untouched. */
const defaultProps = { fields: [] as any[], formAction: '#' };
describe('ContactForm.craft.props includes the destination defaults', () => {
// The trap this pins: FormStylePanel renders each destination control behind
// `nodeProps.X !== undefined`, so a prop omitted from these defaults yields an
// invisible control and the whole feature looks like it does nothing.
test('destinationType/webhookUrl/webhookSecretId/webhookAuthMode are all present', () => {
expect(ContactForm.craft!.props).toMatchObject({
destinationType: 'email',
webhookUrl: '',
webhookSecretId: '',
webhookAuthMode: 'signature',
});
});
test('no craft prop holds a raw secret -- only an id', () => {
const keys = Object.keys(ContactForm.craft!.props as object);
expect(keys).toContain('webhookSecretId');
expect(keys.filter((k) => /secret/i.test(k))).toEqual(['webhookSecretId']);
});
});
describe('ContactForm.toHtml destination marker', () => {
test('email destination emits the legacy marker unchanged', () => {
const out = toHtml(
{ ...defaultProps, destinationType: 'email', recipientEmail: 'a@example.com', thankYouUrl: '' }, '');
expect(out.html).toContain('<!--WHP-FORM');
expect(out.html).toContain('recipient="a@example.com"');
expect(out.html).not.toContain('type="webhook"');
});
test('BYTE-IDENTITY: an email destination emits exactly what a pre-feature form emits', () => {
// The guarantee every already-published site depends on: a marker with no
// `type` still provisions an email endpoint, so its bytes must not drift by
// so much as a space.
//
// FROZEN LITERAL, not a self-comparison. Comparing two head-revision outputs
// to each other only catches a drift that affects ONE of them -- a uniform
// change passes it. This string was captured from `071f3447` (the revision
// deployed to production before this feature) and is the actual reference:
// if it has to be edited, every already-published site's forms have changed
// shape and that is the thing to stop, not the test.
const FROZEN_LEGACY_MARKER =
'<!--WHP-FORM id="F_3hodg" recipient="a@example.com" thankyou="/thx"-->';
const legacy = toHtml({ ...defaultProps, recipientEmail: 'a@example.com', thankYouUrl: '/thx' }, '', 'n1');
expect(legacy.html.startsWith(`${FROZEN_LEGACY_MARKER}<form `)).toBe(true);
// ...and the new props, set to their defaults, change nothing about it.
const explicit = toHtml(
{ ...defaultProps, destinationType: 'email', webhookUrl: '', webhookSecretId: '',
webhookAuthMode: 'signature', recipientEmail: 'a@example.com', thankYouUrl: '/thx' }, '', 'n1');
expect(explicit.html).toBe(legacy.html);
});
test('webhook destination emits type, url, secret id and auth mode', () => {
const out = toHtml(
{ ...defaultProps, destinationType: 'webhook', webhookUrl: 'https://hooks.example.com/x',
webhookSecretId: 'sec-1', webhookAuthMode: 'bearer', recipientEmail: 'fb@example.com' }, '');
expect(out.html).toContain('type="webhook"');
expect(out.html).toContain('url="https://hooks.example.com/x"');
expect(out.html).toContain('secret="sec-1"');
expect(out.html).toContain('authmode="bearer"');
expect(out.html).toContain('recipient="fb@example.com"');
});
test('a raw secret value is never emitted, only its id', () => {
const out = toHtml(
{ ...defaultProps, destinationType: 'webhook', webhookUrl: 'https://hooks.example.com/x',
webhookSecretId: 'sec-1', webhookSecret: 'SUPERSECRET' } as any, '');
// Non-vacuous: the marker IS emitted (so there is something that could have
// carried the secret) and carries the id, but not the value.
expect(out.html).toContain('secret="sec-1"');
expect(out.html).not.toContain('SUPERSECRET');
});
test('a webhook form with no fallback email still emits a marker (never a bare formAction)', () => {
const out = toHtml(
{ ...defaultProps, destinationType: 'webhook', webhookUrl: 'https://hooks.example.com/x' }, '');
expect(out.html).toContain('type="webhook"');
expect(out.html).toContain('recipient=""');
expect(out.html).toMatch(/action="__WHP_FORM_ACTION__F_[0-9a-z]+__"/);
expect(out.html).toContain('name="_gotcha"');
});
});
+54 -7
View File
@@ -2,10 +2,19 @@ import React, { CSSProperties } from 'react';
import { useNode, UserComponent } from '@craftjs/core';
import { cssPropsToString } from '../../utils/style-helpers';
import { relayFormWiring } from '../../utils/form-relay-wiring';
import { escapeHtml, escapeAttr, slugId } from '../../utils/escape';
import { escapeHtml, escapeAttr, slugId, cssValue, sanitizeInputType } from '../../utils/escape';
// The allowlist enforced at export time lives in `sanitizeInputType`
// (utils/escape.ts) -- this union is a superset (it also covers 'textarea'
// and 'select', which take their own render branches instead of an
// `<input type>`), kept in sync by hand since TS unions can't import a
// runtime array.
export type ContactFormFieldType =
| 'text' | 'email' | 'tel' | 'number' | 'password' | 'url' | 'search' | 'date'
| 'checkbox' | 'radio' | 'textarea' | 'select';
interface ContactFormField {
type: 'text' | 'email' | 'tel' | 'textarea' | 'select';
type: ContactFormFieldType;
label: string;
name: string;
placeholder: string;
@@ -25,6 +34,21 @@ interface ContactFormProps {
inputBorder?: string;
recipientEmail?: string;
thankYouUrl?: string;
/* ---- Submission destination (see utils/form-relay-wiring.ts) ----
'email' (default) reproduces the legacy marker exactly. 'webhook' widens it
with the url / secret id / auth mode below.
There is deliberately NO raw-secret prop: craft props are serialised into
the saved project and into published output, so the secret is POSTed to
/api/form-webhook-secret.php and only the returned opaque id is kept. */
destinationType?: 'email' | 'webhook';
webhookUrl?: string;
webhookSecretId?: string;
webhookAuthMode?: 'signature' | 'bearer';
animation?: string;
animationDelay?: string;
hideOnDesktop?: boolean;
hideOnTablet?: boolean;
hideOnMobile?: boolean;
}
const defaultFields: ContactFormField[] = [
@@ -157,6 +181,17 @@ ContactForm.craft = {
inputBorder: '#d1d5db',
recipientEmail: '',
thankYouUrl: '',
// Present (not omitted) so FormStylePanel's `nodeProps.X !== undefined`
// gates actually render the destination controls.
destinationType: 'email',
webhookUrl: '',
webhookSecretId: '',
webhookAuthMode: 'signature',
animation: '',
animationDelay: '',
hideOnDesktop: false,
hideOnTablet: false,
hideOnMobile: false,
},
rules: {
canDrag: () => true,
@@ -175,9 +210,11 @@ ContactForm.craft = {
gap: '20px',
...props.style,
});
const labelColor = props.labelColor || '#374151';
const inputBg = props.inputBg || '#ffffff';
const inputBorder = props.inputBorder || '#d1d5db';
// Sanitized -- raw string-interpolation sinks in inputStyleStr/labelHtml
// below.
const labelColor = cssValue(props.labelColor) || '#374151';
const inputBg = cssValue(props.inputBg) || '#ffffff';
const inputBorder = cssValue(props.inputBorder) || '#d1d5db';
const inputStyleStr = `width:100%;padding:10px 14px;font-size:14px;font-family:Inter,sans-serif;border:1px solid ${inputBorder};border-radius:6px;background-color:${inputBg};color:#1f2937;box-sizing:border-box;outline:none`;
const fieldsHtml = (props.fields || defaultFields).map((field, i) => {
@@ -194,7 +231,7 @@ ContactForm.craft = {
const opts = (field.options || []).map((o) => `<option value="${escapeAttr(o)}">${escapeHtml(o)}</option>`).join('');
inputHtml = `<select id="${escapeAttr(fieldId)}" name="${escapeAttr(field.name)}" style="${inputStyleStr};cursor:pointer"${reqAttr}><option value="">${escapeHtml(field.placeholder || 'Select...')}</option>${opts}</select>`;
} else {
inputHtml = `<input id="${escapeAttr(fieldId)}" type="${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>`;
}).join('\n ');
@@ -212,7 +249,17 @@ ContactForm.craft = {
alignSelf: 'flex-start',
});
const { marker, actionAttr, honeypot } = relayFormWiring(props.recipientEmail, props.thankYouUrl, props.formAction, nodeId);
// Only the webhook SECRET ID travels here -- there is no prop holding the raw
// secret, by construction (see ContactFormProps).
const { marker, actionAttr, honeypot } = relayFormWiring(
props.recipientEmail, props.thankYouUrl, props.formAction, nodeId,
{
type: props.destinationType,
url: props.webhookUrl,
secretId: props.webhookSecretId,
authMode: props.webhookAuthMode,
},
);
// 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
@@ -0,0 +1,40 @@
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;');
});
test('box-model style (margin/border/box-shadow/opacity) flows through via the style prop', () => {
const { html } = toHtml({ text: 'Submit', style: { marginTop: '12px', border: '2px solid #000', boxShadow: '0 2px 4px rgba(0,0,0,.2)', opacity: '0.8' } }, '');
expect(html).toContain('margin-top:12px');
expect(html).toContain('border:2px solid #000');
expect(html).toContain('opacity:0.8');
});
});
describe('FormButton.craft.props includes animation/visibility defaults', () => {
test('has blank/false defaults', () => {
expect(FormButton.craft!.props).toMatchObject({
animation: '', animationDelay: '', hideOnDesktop: false, hideOnTablet: false, hideOnMobile: false,
});
});
});
+12 -1
View File
@@ -1,10 +1,16 @@
import React, { CSSProperties } from 'react';
import { useNode, UserComponent } from '@craftjs/core';
import { cssPropsToString } from '../../utils/style-helpers';
import { escapeHtml } from '../../utils/escape';
interface FormButtonProps {
text?: string;
style?: CSSProperties;
animation?: string;
animationDelay?: string;
hideOnDesktop?: boolean;
hideOnTablet?: boolean;
hideOnMobile?: boolean;
}
export const FormButton: UserComponent<FormButtonProps> = ({
@@ -57,6 +63,11 @@ FormButton.craft = {
fontSize: '16px',
border: 'none',
},
animation: '',
animationDelay: '',
hideOnDesktop: false,
hideOnTablet: false,
hideOnMobile: false,
},
rules: {
canDrag: () => true,
@@ -74,7 +85,7 @@ FormButton.craft = {
cursor: 'pointer',
...props.style,
});
const escapedText = (props.text || 'Submit').replace(/</g, '&lt;').replace(/>/g, '&gt;');
const escapedText = escapeHtml(props.text || 'Submit');
return {
html: `<button type="submit"${styleStr ? ` style="${styleStr}"` : ''}>${escapedText}</button>`,
};
@@ -39,3 +39,33 @@ describe('FormContainer.toHtml relay wiring', () => {
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"');
});
});
describe('FormContainer.toHtml box-model style passthrough', () => {
test('margin/border/box-shadow/opacity flow through via the style prop', () => {
const { html } = toHtml({ action: '/legacy', style: { marginTop: '20px', border: '3px dashed #ccc', boxShadow: '0 4px 8px rgba(0,0,0,.2)', opacity: '0.95' } }, '');
expect(html).toContain('margin-top:20px');
expect(html).toContain('border:3px dashed #ccc');
expect(html).toContain('opacity:0.95');
});
});
describe('FormContainer.craft.props includes animation/visibility defaults', () => {
test('has blank/false defaults', () => {
expect(FormContainer.craft!.props).toMatchObject({
animation: '', animationDelay: '', hideOnDesktop: false, hideOnTablet: false, hideOnMobile: false,
});
});
});
+12 -1
View File
@@ -3,6 +3,7 @@ import { useNode, Element, UserComponent } from '@craftjs/core';
import { Container } from '../layout/Container';
import { cssPropsToString } from '../../utils/style-helpers';
import { relayFormWiring } from '../../utils/form-relay-wiring';
import { sanitizeFormMethod } from '../../utils/escape';
interface FormContainerProps {
action?: string;
@@ -11,6 +12,11 @@ interface FormContainerProps {
thankYouUrl?: string;
style?: CSSProperties;
children?: React.ReactNode;
animation?: string;
animationDelay?: string;
hideOnDesktop?: boolean;
hideOnTablet?: boolean;
hideOnMobile?: boolean;
}
export const FormContainer: UserComponent<FormContainerProps> = ({
@@ -58,6 +64,11 @@ FormContainer.craft = {
borderRadius: '8px',
border: '1px solid #e4e4e7',
},
animation: '',
animationDelay: '',
hideOnDesktop: false,
hideOnTablet: false,
hideOnMobile: false,
},
rules: {
canDrag: () => true,
@@ -74,7 +85,7 @@ FormContainer.craft = {
...props.style,
});
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
return {
html: `${marker}<form action="${actionAttr}" method="${method}"${styleStr ? ` style="${styleStr}"` : ''}>${body}</form>`,
@@ -58,3 +58,34 @@ describe('InputField.toHtml deterministic + unique ids (thread node id, resolves
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"');
});
});
describe('InputField.toHtml box-model style passthrough', () => {
test('margin/border/box-shadow/opacity flow through via the style prop', () => {
const { html } = toHtml({ label: 'Name', name: 'name', style: { marginBottom: '8px', border: '1px solid #333', boxShadow: '0 1px 2px rgba(0,0,0,.1)', opacity: '0.9' } }, '');
expect(html).toContain('margin-bottom:8px');
expect(html).toContain('border:1px solid #333');
expect(html).toContain('opacity:0.9');
});
});
describe('InputField.craft.props includes animation/visibility defaults', () => {
test('has blank/false defaults', () => {
expect(InputField.craft!.props).toMatchObject({
animation: '', animationDelay: '', hideOnDesktop: false, hideOnTablet: false, hideOnMobile: false,
});
});
});
+12 -2
View File
@@ -1,7 +1,7 @@
import React, { CSSProperties } from 'react';
import { useNode, UserComponent } from '@craftjs/core';
import { cssPropsToString } from '../../utils/style-helpers';
import { escapeHtml, escapeAttr, scopeId } from '../../utils/escape';
import { escapeHtml, escapeAttr, scopeId, sanitizeInputType } from '../../utils/escape';
interface InputFieldProps {
label?: string;
@@ -10,6 +10,11 @@ interface InputFieldProps {
placeholder?: string;
required?: boolean;
style?: CSSProperties;
animation?: string;
animationDelay?: string;
hideOnDesktop?: boolean;
hideOnTablet?: boolean;
hideOnMobile?: boolean;
}
export const InputField: UserComponent<InputFieldProps> = ({
@@ -77,6 +82,11 @@ InputField.craft = {
placeholder: 'Enter your name',
required: false,
style: {},
animation: '',
animationDelay: '',
hideOnDesktop: false,
hideOnTablet: false,
hideOnMobile: false,
},
rules: {
canDrag: () => true,
@@ -111,7 +121,7 @@ InputField.craft = {
return {
html: `<div${wrapStyle ? ` style="${wrapStyle}"` : ''}>
${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>`,
};
};
@@ -0,0 +1,81 @@
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).toMatch(/<form action="[^"]*" 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<');
});
});
// F1: SubscribeForm previously emitted `<form method="POST">` with no action
// at all -- a published subscribe form silently did nothing on submit.
// Wired through the same relay contract as ContactForm/FormContainer
// (utils/form-relay-wiring.ts) so setting a recipient makes it functional.
describe('SubscribeForm.toHtml is functional (not a dead POST)', () => {
test('without a recipient: still has a real (non-empty) action -- "#" fallback, not a bare method="POST"', () => {
const { html } = toHtml({}, '');
expect(html).toMatch(/<form action="#" method="POST"/);
});
test('with recipientEmail: emits the relay marker, placeholder action, and honeypot -- a working submission path', () => {
const { html } = toHtml({ recipientEmail: 'news@example.com', thankYouUrl: '/thanks' }, '', 'node-sub1');
expect(html).toMatch(/<!--WHP-FORM id="F_[0-9a-z]+" recipient="news@example.com" thankyou="\/thanks"-->/);
expect(html).toMatch(/action="__WHP_FORM_ACTION__F_[0-9a-z]+__"/);
expect(html).toContain('name="_gotcha"');
const mid = html.match(/id="(F_[0-9a-z]+)"/)![1];
expect(html).toContain(`__WHP_FORM_ACTION__${mid}__`);
});
test('the email input keeps its name="email" so the relay receives it', () => {
const { html } = toHtml({ recipientEmail: 'news@example.com' }, '', 'node-sub2');
expect(html).toContain('name="email"');
});
});
describe('SubscribeForm.toHtml box-model style passthrough', () => {
test('margin/border/box-shadow/opacity flow through via the style prop', () => {
const { html } = toHtml({ style: { marginTop: '16px', border: '1px solid #ddd', boxShadow: '0 2px 6px rgba(0,0,0,.15)', opacity: '0.85' } }, '');
expect(html).toContain('margin-top:16px');
expect(html).toContain('border:1px solid #ddd');
expect(html).toContain('opacity:0.85');
});
});
describe('SubscribeForm.craft.props includes animation/visibility defaults', () => {
test('has blank/false defaults', () => {
expect(SubscribeForm.craft!.props).toMatchObject({
animation: '', animationDelay: '', hideOnDesktop: false, hideOnTablet: false, hideOnMobile: false,
});
});
});
+33 -4
View File
@@ -1,6 +1,7 @@
import React, { CSSProperties } from 'react';
import { useNode, UserComponent } from '@craftjs/core';
import { cssPropsToString } from '../../utils/style-helpers';
import { relayFormWiring } from '../../utils/form-relay-wiring';
import { escapeHtml, escapeAttr } from '../../utils/escape';
interface SubscribeFormProps {
@@ -10,6 +11,17 @@ interface SubscribeFormProps {
buttonColor?: string;
layout?: 'inline' | 'stacked';
style?: CSSProperties;
/** "Send submissions to" address -- same relay contract as ContactForm/
* FormContainer (see utils/form-relay-wiring.ts). Blank = no relay; the
* published form then has no working action at all, which is the bug
* this prop exists to fix. */
recipientEmail?: string;
thankYouUrl?: string;
animation?: string;
animationDelay?: string;
hideOnDesktop?: boolean;
hideOnTablet?: boolean;
hideOnMobile?: boolean;
}
export const SubscribeForm: UserComponent<SubscribeFormProps> = ({
@@ -110,6 +122,13 @@ SubscribeForm.craft = {
buttonColor: '#3b82f6',
layout: 'inline',
style: { backgroundColor: '#f8fafc' },
recipientEmail: '',
thankYouUrl: '',
animation: '',
animationDelay: '',
hideOnDesktop: false,
hideOnTablet: false,
hideOnMobile: false,
},
rules: {
canDrag: () => true,
@@ -120,7 +139,7 @@ SubscribeForm.craft = {
/* ---------- HTML export ---------- */
(SubscribeForm as any).toHtml = (props: SubscribeFormProps, _childrenHtml: string) => {
(SubscribeForm as any).toHtml = (props: SubscribeFormProps, _childrenHtml: string, nodeId?: string) => {
const {
heading = 'Subscribe to our newsletter',
placeholder = 'Enter your email',
@@ -166,11 +185,21 @@ SubscribeForm.craft = {
whiteSpace: 'nowrap',
});
// Same relay contract as ContactForm/FormContainer: a recipientEmail wires
// the form through the WHP form-sender relay (marker + placeholder action
// + honeypot, provisioned/rewritten at publish time). Previously this form
// always emitted `<form method="POST">` with NO action at all -- a
// published subscribe form silently did nothing on submit. Falling back to
// `formAction`-less relay wiring (fallbackAction undefined -> '#') keeps
// the old no-recipient case visually identical (action="#") while making
// the relay path actually functional once an admin sets an email.
const { marker, actionAttr, honeypot } = relayFormWiring(props.recipientEmail, props.thankYouUrl, undefined, nodeId);
return {
html: `<div${wrapperStyle ? ` style="${wrapperStyle}"` : ''}>
html: `${marker}<div${wrapperStyle ? ` style="${wrapperStyle}"` : ''}>
${headingHtml}
<form method="POST"${formStyle ? ` style="${formStyle}"` : ''}>
<input type="email" name="email" placeholder="${escapeAttr(placeholder)}" required style="${inputStyleStr}" />
<form action="${actionAttr}" method="POST"${formStyle ? ` style="${formStyle}"` : ''}>
${honeypot ? ` ${honeypot}\n` : ''} <input type="email" name="email" placeholder="${escapeAttr(placeholder)}" required style="${inputStyleStr}" />
<button type="submit"${btnStyle ? ` style="${btnStyle}"` : ''}>${escapeHtml(buttonText)}</button>
</form>
</div>`,
@@ -50,3 +50,33 @@ describe('TextareaField.toHtml deterministic + unique ids (thread node id, resol
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"');
});
});
describe('TextareaField.toHtml box-model style passthrough', () => {
test('margin/border/box-shadow/opacity flow through via the style prop', () => {
const { html } = toHtml({ label: 'Message', name: 'message', style: { marginTop: '10px', border: '1px solid #555', boxShadow: '0 1px 3px rgba(0,0,0,.15)', opacity: '0.7' } }, '');
expect(html).toContain('margin-top:10px');
expect(html).toContain('border:1px solid #555');
expect(html).toContain('opacity:0.7');
});
});
describe('TextareaField.craft.props includes animation/visibility defaults', () => {
test('has blank/false defaults', () => {
expect(TextareaField.craft!.props).toMatchObject({
animation: '', animationDelay: '', hideOnDesktop: false, hideOnTablet: false, hideOnMobile: false,
});
});
});
+16 -1
View File
@@ -10,6 +10,11 @@ interface TextareaFieldProps {
rows?: number;
required?: boolean;
style?: CSSProperties;
animation?: string;
animationDelay?: string;
hideOnDesktop?: boolean;
hideOnTablet?: boolean;
hideOnMobile?: boolean;
}
export const TextareaField: UserComponent<TextareaFieldProps> = ({
@@ -79,6 +84,11 @@ TextareaField.craft = {
rows: 4,
required: false,
style: {},
animation: '',
animationDelay: '',
hideOnDesktop: false,
hideOnTablet: false,
hideOnMobile: false,
},
rules: {
canDrag: () => true,
@@ -97,6 +107,11 @@ TextareaField.craft = {
...props.style,
});
const reqAttr = props.required ? ' required' : '';
// `rows` is declared as a TS `number` but arrives unchecked (AI update_props
// path only validates node_id; deserialized saved-state JSON is untyped at
// runtime), so a string like `4"><script>...` must be coerced to a real
// number before interpolation, not trusted as already-numeric.
const rows = Number(props.rows) || 4;
// Deterministic AND unique id: scoped on the Craft node id so the
// <label for> always matches the <textarea id> AND two TextareaField
// instances that share the same (often default) `name` -- e.g. two
@@ -113,7 +128,7 @@ TextareaField.craft = {
return {
html: `<div${wrapStyle ? ` style="${wrapStyle}"` : ''}>
${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>`,
};
};
@@ -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,77 @@ describe('ColumnLayout.toHtml deterministic + unique scope ids (thread node id,
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\)/);
});
});
describe('ColumnLayout.toHtml vertical alignment (align-items on the flex row)', () => {
test('style.alignItems flows into the emitted style attribute (aligns uneven columns)', () => {
const { html } = toHtml({ columns: 2, split: '50-50', gap: '16px', style: { alignItems: 'center' } }, '<div>A</div><div>B</div>');
expect(html).toContain('align-items:center');
});
});
describe('ColumnLayout.toHtml box-model styles (margin/padding/border/shadow/opacity)', () => {
test('margin/padding/border/box-shadow/opacity all flow into the emitted style attribute', () => {
const { html } = toHtml(
{
columns: 2,
split: '50-50',
gap: '16px',
style: {
marginTop: '10px', marginRight: '10px', marginBottom: '10px', marginLeft: '10px',
paddingTop: '5px',
border: '2px solid #ff0000',
boxShadow: '0 4px 8px rgba(0,0,0,0.12)',
opacity: '0.8',
},
},
'<div>A</div><div>B</div>',
);
expect(html).toContain('margin-top:10px');
expect(html).toContain('padding-top:5px');
expect(html).toContain('border:2px solid #ff0000');
expect(html).toContain('box-shadow:0 4px 8px rgba(0,0,0,0.12)');
expect(html).toContain('opacity:0.8');
});
});
describe('ColumnLayout.craft.props exposes the vertical-alignment/box-model/animation/visibility rollout', () => {
test('animation, animationDelay, hideOnDesktop/Tablet/Mobile are present with blank/false defaults', () => {
const props = (ColumnLayout as any).craft.props;
expect(props.animation).toBe('');
expect(props.animationDelay).toBe('0');
expect(props.hideOnDesktop).toBe(false);
expect(props.hideOnTablet).toBe(false);
expect(props.hideOnMobile).toBe(false);
});
test('style carries blank/default alignItems and box-model keys', () => {
const style = (ColumnLayout as any).craft.props.style;
expect(style).toHaveProperty('alignItems');
expect(style).toHaveProperty('marginTop');
expect(style).toHaveProperty('paddingTop');
expect(style.border).toBe('none');
expect(style.boxShadow).toBe('none');
expect(style.opacity).toBe('1');
});
});
+24 -3
View File
@@ -2,7 +2,7 @@ import React, { CSSProperties } from 'react';
import { useNode, Element, UserComponent } from '@craftjs/core';
import { Container } from './Container';
import { cssPropsToString } from '../../utils/style-helpers';
import { escapeAttr, scopeId } from '../../utils/escape';
import { escapeAttr, scopeId, cssValue } from '../../utils/escape';
type SplitOption =
| '100'
@@ -20,6 +20,11 @@ interface ColumnLayoutProps {
style?: CSSProperties;
children?: React.ReactNode;
anchorId?: string;
hideOnDesktop?: boolean;
hideOnTablet?: boolean;
hideOnMobile?: boolean;
animation?: string;
animationDelay?: string;
}
const splitToWidths: Record<string, string[]> = {
@@ -102,8 +107,20 @@ ColumnLayout.craft = {
columns: 2,
split: '50-50',
gap: '16px',
style: {},
style: {
alignItems: '',
marginTop: '', marginRight: '', marginBottom: '', marginLeft: '',
paddingTop: '', paddingRight: '', paddingBottom: '', paddingLeft: '',
border: 'none',
boxShadow: 'none',
opacity: '1',
},
anchorId: '',
animation: '',
animationDelay: '0',
hideOnDesktop: false,
hideOnTablet: false,
hideOnMobile: false,
},
rules: {
canDrag: () => true,
@@ -117,7 +134,11 @@ ColumnLayout.craft = {
(ColumnLayout as any).toHtml = (props: ColumnLayoutProps, childrenHtml: string, nodeId?: string) => {
const columns = props.columns || 2;
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 outerStyle = cssPropsToString({
@@ -33,3 +33,118 @@ describe('Container.toHtml cssId/cssClass', () => {
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);
}
});
});
describe('Container.toHtml vertical alignment (justify-content + min-height)', () => {
// Regression lock: Container/Section must NOT unconditionally become a
// flex container. Flex-blockifies in-flow children, forcing components
// that deliberately render display:inline-block (ButtonLink, Icon) to
// stack vertically instead of sitting side-by-side -- a real visual
// regression for existing published pages that never touch vertical
// alignment.
test('does NOT become a flex container when no vertical alignment is set (plain block flow preserved)', () => {
const { html } = toHtml({}, 'child');
expect(html).not.toContain('display:flex');
expect(html).not.toContain('flex-direction');
});
test('does NOT become a flex container from min-height alone (min-height must not itself trigger flex)', () => {
const { html } = toHtml({ style: { minHeight: '400px' } }, 'child');
expect(html).not.toContain('display:flex');
expect(html).not.toContain('flex-direction');
expect(html).toContain('min-height:400px');
});
test('becomes a column flex container when style.justifyContent is set (feature still works)', () => {
const { html } = toHtml({ style: { justifyContent: 'center' } }, 'child');
expect(html).toContain('display:flex');
expect(html).toContain('flex-direction:column');
expect(html).toContain('justify-content:center');
});
test('style.minHeight flows into the emitted style attribute', () => {
const { html } = toHtml({ style: { minHeight: '400px' } }, 'child');
expect(html).toContain('min-height:400px');
});
test('justify-content and min-height still flow through in boxed (contentWidth) mode', () => {
const { html } = toHtml({ contentWidth: 'boxed', style: { justifyContent: 'flex-end', minHeight: '500px' } }, 'child');
expect(html).toContain('display:flex');
expect(html).toContain('flex-direction:column');
expect(html).toContain('justify-content:flex-end');
expect(html).toContain('min-height:500px');
});
});
describe('Container.toHtml box-model styles (margin/padding/border/shadow/opacity)', () => {
test('margin/padding/border/box-shadow/opacity all flow into the emitted style attribute', () => {
const { html } = toHtml(
{
style: {
marginTop: '10px', marginRight: '10px', marginBottom: '10px', marginLeft: '10px',
paddingTop: '5px',
border: '2px solid #ff0000',
boxShadow: '0 4px 8px rgba(0,0,0,0.12)',
opacity: '0.8',
},
},
'child',
);
expect(html).toContain('margin-top:10px');
expect(html).toContain('padding-top:5px');
expect(html).toContain('border:2px solid #ff0000');
expect(html).toContain('box-shadow:0 4px 8px rgba(0,0,0,0.12)');
expect(html).toContain('opacity:0.8');
});
});
describe('Container.craft.props exposes the vertical-alignment/box-model/animation/visibility rollout', () => {
test('animation, animationDelay, hideOnDesktop/Tablet/Mobile are present with blank/false defaults', () => {
const props = (Container as any).craft.props;
expect(props.animation).toBe('');
expect(props.animationDelay).toBe('0');
expect(props.hideOnDesktop).toBe(false);
expect(props.hideOnTablet).toBe(false);
expect(props.hideOnMobile).toBe(false);
});
test('style carries blank/default vertical-alignment and box-model keys', () => {
const style = (Container as any).craft.props.style;
expect(style).toHaveProperty('justifyContent');
expect(style).toHaveProperty('minHeight');
expect(style).toHaveProperty('marginTop');
expect(style).toHaveProperty('paddingTop');
expect(style.border).toBe('none');
expect(style.boxShadow).toBe('none');
expect(style.opacity).toBe('1');
});
});
+49 -3
View File
@@ -3,6 +3,19 @@ import { useNode, UserComponent } from '@craftjs/core';
import { cssPropsToString } from '../../utils/style-helpers';
import { escapeAttr } from '../../utils/escape';
// The only tag names Container actually supports (matches the TS union
// below and the `tag` default in `.craft.props`). `tag` is settable via the
// AI `update_props` path and from deserialized saved state -- neither is
// type-checked at runtime -- so a malicious value like
// `div><img src=x onerror=alert(1)` must never reach the `<${tag}` template
// position in `toHtml`/the live render. Anything not in this allowlist
// falls back to `'div'`.
const ALLOWED_CONTAINER_TAGS = ['div', 'section', 'article', 'header', 'footer', 'main'] as const;
export type ContainerTag = (typeof ALLOWED_CONTAINER_TAGS)[number];
export const sanitizeContainerTag = (tag: unknown): ContainerTag =>
(ALLOWED_CONTAINER_TAGS as readonly unknown[]).includes(tag) ? (tag as ContainerTag) : 'div';
interface ContainerProps {
style?: CSSProperties;
tag?: 'div' | 'section' | 'article' | 'header' | 'footer' | 'main';
@@ -30,6 +43,20 @@ const flexAlignFromTextAlign = (textAlign: CSSProperties['textAlign']): CSSPrope
return {};
};
// Container only becomes display:flex/flex-direction:column at its root
// (both in the editor render below and in toHtml) when the user has
// actually set `style.justifyContent` (the Vertical Alignment control,
// paired with `style.minHeight`) -- i.e. the flex conversion is gated on
// vertical-align actually being in use, not unconditional. In-flow children
// of a flex container get CSS-blockified, which would force components that
// deliberately render `display:inline-block` (ButtonLink, Icon) to stack
// vertically instead of sitting side-by-side -- a real visual regression for
// any container/section that never touches vertical alignment, not a no-op.
// So plain block flow (no `display`/`flex-direction` at all) is preserved
// unless vertical-align is set. `flexAlignFromTextAlign` above still
// supplies its own conditional flex conversion (cross-axis alignItems from
// `textAlign`) independently -- unrelated to this gate.
export const Container: UserComponent<ContainerProps> = ({
style = {},
tag = 'div',
@@ -42,12 +69,15 @@ export const Container: UserComponent<ContainerProps> = ({
}) => {
const { connectors: { connect, drag } } = useNode();
const safeTag = sanitizeContainerTag(tag);
const needsBoxedWrapper = contentWidth === 'boxed';
const flexStyles = flexAlignFromTextAlign(style.textAlign);
const hasVerticalAlign = !!style.justifyContent;
const outerStyle: CSSProperties = {
minHeight: '40px',
...style,
...(hasVerticalAlign ? { display: 'flex', flexDirection: 'column' } : {}),
...(fullWidth ? { width: '100vw', marginLeft: 'calc(-50vw + 50%)' } : {}),
...(needsBoxedWrapper ? {} : flexStyles),
};
@@ -58,7 +88,7 @@ export const Container: UserComponent<ContainerProps> = ({
const idValue = cssId || anchorId || undefined;
const el = React.createElement(
tag,
safeTag,
{
ref: (ref: HTMLElement | null): void => { if (ref) connect(drag(ref)); },
style: outerStyle,
@@ -79,13 +109,27 @@ export const Container: UserComponent<ContainerProps> = ({
Container.craft = {
displayName: 'Container',
props: {
style: { padding: '20px', minHeight: '100px' },
style: {
padding: '20px',
minHeight: '100px',
justifyContent: '',
marginTop: '', marginRight: '', marginBottom: '', marginLeft: '',
paddingTop: '', paddingRight: '', paddingBottom: '', paddingLeft: '',
border: 'none',
boxShadow: 'none',
opacity: '1',
},
tag: 'div',
fullWidth: false,
contentWidth: 'full',
anchorId: '',
cssId: '',
cssClass: '',
animation: '',
animationDelay: '0',
hideOnDesktop: false,
hideOnTablet: false,
hideOnMobile: false,
},
rules: {
canDrag: () => true,
@@ -97,12 +141,14 @@ Container.craft = {
/* ---------- HTML export ---------- */
(Container as any).toHtml = (props: ContainerProps, childrenHtml: string) => {
const tag = props.tag || 'div';
const tag = sanitizeContainerTag(props.tag);
const isBoxed = props.contentWidth === 'boxed';
const flexStyles = flexAlignFromTextAlign(props.style?.textAlign);
const hasVerticalAlign = !!props.style?.justifyContent;
const outerCss: CSSProperties = {
...props.style,
...(hasVerticalAlign ? { display: 'flex', flexDirection: 'column' } : {}),
...(isBoxed ? {} : flexStyles),
};
@@ -0,0 +1,150 @@
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');
});
});
describe('Section.toHtml vertical alignment (justify-content + min-height)', () => {
// Regression lock: same rationale as Container -- see Container.toHtml.test.ts.
// Section must not unconditionally become a flex container, or it
// blockifies inline-block children (ButtonLink, Icon) that are meant to
// sit side-by-side in existing published sections.
test('does NOT become a flex container when no vertical alignment is set (plain block flow preserved)', () => {
const { html } = toHtml({}, 'child');
expect(html).not.toContain('display:flex');
expect(html).not.toContain('flex-direction');
});
test('does NOT become a flex container from min-height alone (min-height must not itself trigger flex)', () => {
const { html } = toHtml({ style: { minHeight: '600px' } }, 'child');
expect(html).not.toContain('display:flex');
expect(html).not.toContain('flex-direction');
expect(html).toContain('min-height:600px');
});
test('becomes a column flex container when style.justifyContent is set (feature still works)', () => {
const { html } = toHtml({ style: { justifyContent: 'center' } }, 'child');
expect(html).toContain('display:flex');
expect(html).toContain('flex-direction:column');
expect(html).toContain('justify-content:center');
});
test('style.minHeight flows into the emitted style attribute', () => {
const { html } = toHtml({ style: { minHeight: '600px' } }, 'child');
expect(html).toContain('min-height:600px');
});
});
describe('Section.toHtml box-model styles (margin/padding/border/shadow/opacity)', () => {
test('margin/padding/border/box-shadow/opacity all flow into the emitted style attribute', () => {
const { html } = toHtml(
{
style: {
marginTop: '10px', marginRight: '10px', marginBottom: '10px', marginLeft: '10px',
paddingTop: '5px',
border: '2px solid #ff0000',
boxShadow: '0 4px 8px rgba(0,0,0,0.12)',
opacity: '0.8',
},
},
'child',
);
expect(html).toContain('margin-top:10px');
expect(html).toContain('padding-top:5px');
expect(html).toContain('border:2px solid #ff0000');
expect(html).toContain('box-shadow:0 4px 8px rgba(0,0,0,0.12)');
expect(html).toContain('opacity:0.8');
});
});
describe('Section.craft.props exposes the vertical-alignment/box-model/animation/visibility rollout', () => {
test('animation, animationDelay, hideOnDesktop/Tablet/Mobile are present with blank/false defaults', () => {
const props = (Section as any).craft.props;
expect(props.animation).toBe('');
expect(props.animationDelay).toBe('0');
expect(props.hideOnDesktop).toBe(false);
expect(props.hideOnTablet).toBe(false);
expect(props.hideOnMobile).toBe(false);
});
test('style carries blank/default vertical-alignment and box-model keys', () => {
const style = (Section as any).craft.props.style;
expect(style).toHaveProperty('justifyContent');
expect(style).toHaveProperty('minHeight');
expect(style).toHaveProperty('marginTop');
expect(style).toHaveProperty('paddingTop');
expect(style.border).toBe('none');
expect(style.boxShadow).toBe('none');
expect(style.opacity).toBe('1');
});
});
+46 -5
View File
@@ -2,7 +2,7 @@ import React, { CSSProperties } from 'react';
import { useNode, Element, UserComponent } from '@craftjs/core';
import { cssPropsToString } from '../../utils/style-helpers';
import { Container } from './Container';
import { escapeAttr } from '../../utils/escape';
import { escapeAttr, cssValue } from '../../utils/escape';
/* ---------- Shape Divider SVG Paths ---------- */
@@ -27,6 +27,11 @@ interface SectionProps {
bottomDividerColor?: string;
bottomDividerHeight?: string;
anchorId?: string;
hideOnDesktop?: boolean;
hideOnTablet?: boolean;
hideOnMobile?: boolean;
animation?: string;
animationDelay?: string;
}
/* ---------- Divider renderer ---------- */
@@ -38,7 +43,14 @@ const ShapeDivider: React.FC<{
position: 'top' | 'bottom';
}> = ({ shape, color, height, position }) => {
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;
const isTop = position === 'top';
@@ -91,6 +103,13 @@ export const Section: UserComponent<SectionProps> = ({
const hasTopDivider = topDivider && topDivider !== 'none';
const hasBottomDivider = bottomDivider && bottomDivider !== 'none';
// Section's root only becomes a column flex container when the user has
// actually set `style.justifyContent` (Vertical Alignment control, paired
// with `style.minHeight`) -- see the matching note in Container.tsx for
// why an unconditional conversion is a real regression (blockifies
// deliberately inline-block children like ButtonLink/Icon) rather than a
// no-op, so plain block flow is preserved unless vertical-align is set.
const hasVerticalAlign = !!style.justifyContent;
return (
<section
@@ -100,6 +119,7 @@ export const Section: UserComponent<SectionProps> = ({
width: '100%',
position: (hasTopDivider || hasBottomDivider) ? 'relative' : undefined,
...style,
...(hasVerticalAlign ? { display: 'flex', flexDirection: 'column' } : {}),
}}
>
{hasTopDivider && (
@@ -136,7 +156,17 @@ export const Section: UserComponent<SectionProps> = ({
Section.craft = {
displayName: 'Section',
props: {
style: { padding: '40px 0', backgroundColor: '#ffffff' },
style: {
padding: '40px 0',
backgroundColor: '#ffffff',
minHeight: '',
justifyContent: '',
marginTop: '', marginRight: '', marginBottom: '', marginLeft: '',
paddingTop: '', paddingRight: '', paddingBottom: '', paddingLeft: '',
border: 'none',
boxShadow: 'none',
opacity: '1',
},
innerMaxWidth: '1200px',
topDivider: 'none',
topDividerColor: '#ffffff',
@@ -145,6 +175,11 @@ Section.craft = {
bottomDividerColor: '#ffffff',
bottomDividerHeight: '50px',
anchorId: '',
animation: '',
animationDelay: '0',
hideOnDesktop: false,
hideOnTablet: false,
hideOnMobile: false,
},
rules: {
canDrag: () => true,
@@ -162,12 +197,16 @@ function buildDividerHtml(
position: 'top' | 'bottom',
): string {
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 '';
const isTop = position === 'top';
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({
position: 'absolute',
@@ -188,11 +227,13 @@ function buildDividerHtml(
(Section as any).toHtml = (props: SectionProps, childrenHtml: string) => {
const hasTopDivider = props.topDivider && props.topDivider !== 'none';
const hasBottomDivider = props.bottomDivider && props.bottomDivider !== 'none';
const hasVerticalAlign = !!props.style?.justifyContent;
const outerStyle = cssPropsToString({
width: '100%',
position: (hasTopDivider || hasBottomDivider) ? 'relative' : undefined,
...props.style,
...(hasVerticalAlign ? { display: 'flex', flexDirection: 'column' } : {}),
});
const innerStyle = cssPropsToString({
maxWidth: props.innerMaxWidth || '1200px',
@@ -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,126 @@
import { describe, test, expect } from 'vitest';
import { ImageBlock, pxAttr } 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"');
});
});
describe('ImageBlock.toHtml perf attributes (always emitted)', () => {
test('loading="lazy" and decoding="async" are always present', () => {
const { html } = toHtml({ src: 'https://example.com/photo.jpg' }, '');
expect(html).toContain('loading="lazy"');
expect(html).toContain('decoding="async"');
});
test('width/height attributes are emitted when the style has plain px values', () => {
const { html } = toHtml({ src: 'https://example.com/photo.jpg', style: { width: '400px', height: '300px' } }, '');
expect(html).toContain('width="400"');
expect(html).toContain('height="300"');
});
test('width/height attributes are omitted when the style value is not a plain px length', () => {
const { html } = toHtml({ src: 'https://example.com/photo.jpg', style: { width: '50%', height: 'auto' } }, '');
expect(html).not.toMatch(/\swidth="/);
expect(html).not.toMatch(/\sheight="/);
});
});
describe('pxAttr', () => {
test('extracts the numeric portion of a plain px length', () => {
expect(pxAttr('400px')).toBe('400');
expect(pxAttr('12.5px')).toBe('12.5');
});
test('returns undefined for non-px units, non-string, or unset values', () => {
expect(pxAttr('50%')).toBeUndefined();
expect(pxAttr('auto')).toBeUndefined();
expect(pxAttr(undefined)).toBeUndefined();
expect(pxAttr(400)).toBeUndefined();
});
});
describe('ImageBlock.toHtml CSS framing crop (aspect-ratio + object-fit + object-position)', () => {
test('style.aspectRatio, objectFit, objectPosition all flow into the emitted style attribute', () => {
const { html } = toHtml(
{ src: 'https://example.com/photo.jpg', style: { aspectRatio: '16 / 9', objectFit: 'cover', objectPosition: 'center top' } },
''
);
expect(html).toContain('aspect-ratio:16 / 9');
expect(html).toContain('object-fit:cover');
expect(html).toContain('object-position:center top');
});
});
describe('ImageBlock.toHtml box-model styles (margin/padding/border/shadow/opacity)', () => {
test('margin/padding/border/box-shadow/opacity all flow into the emitted style attribute', () => {
const { html } = toHtml(
{
src: 'https://example.com/photo.jpg',
style: {
marginTop: '10px', marginRight: '10px', marginBottom: '10px', marginLeft: '10px',
paddingTop: '5px',
border: '2px solid #ff0000',
boxShadow: '0 4px 8px rgba(0,0,0,0.12)',
opacity: '0.8',
},
},
''
);
expect(html).toContain('margin-top:10px');
expect(html).toContain('padding-top:5px');
expect(html).toContain('border:2px solid #ff0000');
expect(html).toContain('box-shadow:0 4px 8px rgba(0,0,0,0.12)');
expect(html).toContain('opacity:0.8');
});
});
describe('ImageBlock.craft.props exposes the box-model/animation/visibility rollout', () => {
test('animation, animationDelay, hideOnDesktop/Tablet/Mobile are present with blank/false defaults', () => {
const props = (ImageBlock as any).craft.props;
expect(props.animation).toBe('');
expect(props.animationDelay).toBe('0');
expect(props.hideOnDesktop).toBe(false);
expect(props.hideOnTablet).toBe(false);
expect(props.hideOnMobile).toBe(false);
});
test('style carries blank/default box-model and crop keys', () => {
const style = (ImageBlock as any).craft.props.style;
expect(style).toHaveProperty('aspectRatio');
expect(style).toHaveProperty('objectFit');
expect(style).toHaveProperty('objectPosition');
expect(style).toHaveProperty('marginTop');
expect(style).toHaveProperty('paddingTop');
expect(style.border).toBe('none');
expect(style.boxShadow).toBe('none');
expect(style.opacity).toBe('1');
});
});
+45 -5
View File
@@ -1,14 +1,31 @@
import React, { CSSProperties, useCallback, useRef } from 'react';
import { useNode, UserComponent } from '@craftjs/core';
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 {
src?: string;
alt?: string;
style?: CSSProperties;
animation?: string;
animationDelay?: string;
hideOnDesktop?: boolean;
hideOnTablet?: boolean;
hideOnMobile?: boolean;
}
/** Extracts the numeric portion of a plain "<n>px" CSS length string, for
* emitting real `width`/`height` HTML attributes on the exported `<img>`
* (helps the browser reserve layout space before the image loads --
* avoiding CLS -- something a CSS-only width/height can't do on its own).
* Returns undefined for any other unit ('%', 'auto', '', etc.) so the
* attribute is simply omitted when the pixel size isn't known. */
export function pxAttr(v: unknown): string | undefined {
if (typeof v !== 'string') return undefined;
const m = v.trim().match(/^(\d+(?:\.\d+)?)px$/);
return m ? m[1] : undefined;
}
// Helper: upload a file to the WHP API and return the proxy URL
@@ -66,7 +83,7 @@ export const ImageBlock: UserComponent<ImageBlockProps> = ({
imgRef.current = ref;
if (ref) connect(drag(ref));
}}
src={src}
src={src || PLACEHOLDER_SRC}
alt={alt || 'Image'}
onDrop={handleDrop}
onDragOver={handleDragOver}
@@ -83,7 +100,27 @@ export const ImageBlock: UserComponent<ImageBlockProps> = ({
ImageBlock.craft = {
displayName: 'Image',
props: { src: PLACEHOLDER_SRC, alt: '', style: { width: '100%', height: 'auto' } },
props: {
src: PLACEHOLDER_SRC,
alt: '',
style: {
width: '100%',
height: 'auto',
aspectRatio: '',
objectFit: '' as CSSProperties['objectFit'],
objectPosition: '',
marginTop: '', marginRight: '', marginBottom: '', marginLeft: '',
paddingTop: '', paddingRight: '', paddingBottom: '', paddingLeft: '',
border: 'none',
boxShadow: 'none',
opacity: '1',
},
animation: '',
animationDelay: '0',
hideOnDesktop: false,
hideOnTablet: false,
hideOnMobile: false,
},
rules: { canDrag: () => true, canMoveIn: () => false, canMoveOut: () => true },
};
@@ -95,5 +132,8 @@ ImageBlock.craft = {
}
const s = cssPropsToString({ display: 'block', maxWidth: '100%', ...props.style });
const alt = props.alt ? ` alt="${escapeAttr(props.alt)}"` : ' alt=""';
return { html: `<img src="${escapeAttr(safeUrl(src))}"${alt}${s ? ` style="${s}"` : ''} />` };
const widthAttr = pxAttr((props.style as any)?.width);
const heightAttr = pxAttr((props.style as any)?.height);
const dims = `${widthAttr ? ` width="${widthAttr}"` : ''}${heightAttr ? ` height="${heightAttr}"` : ''}`;
return { html: `<img src="${escapeAttr(safeImageUrl(src))}"${alt}${dims} loading="lazy" decoding="async"${s ? ` style="${s}"` : ''} />` };
};
@@ -27,3 +27,84 @@ describe('MapEmbed.toHtml iframe src ampersand encoding (F-export review Minor)'
expect(srcMatch![1]).not.toMatch(/&(?!amp;)/);
});
});
describe('MapEmbed.toHtml box-model styles (margin/padding/border/shadow/opacity)', () => {
test('margin/padding/border/box-shadow/opacity all flow into the wrapper style attribute', () => {
const { html } = toHtml(
{
address: 'New York, NY',
style: {
marginTop: '16px',
paddingRight: '4px',
border: '1px solid #cccccc',
boxShadow: '0 4px 8px rgba(0,0,0,0.12)',
opacity: '0.95',
},
},
''
);
expect(html).toContain('margin-top:16px');
expect(html).toContain('padding-right:4px');
expect(html).toContain('border:1px solid #cccccc');
expect(html).toContain('box-shadow:0 4px 8px rgba(0,0,0,0.12)');
expect(html).toContain('opacity:0.95');
});
});
describe('MapEmbed.craft.props exposes the animation/visibility rollout', () => {
test('animation, animationDelay, hideOnDesktop/Tablet/Mobile are present with blank/false defaults', () => {
const props = (MapEmbed as any).craft.props;
expect(props.animation).toBe('');
expect(props.animationDelay).toBe('0');
expect(props.hideOnDesktop).toBe(false);
expect(props.hideOnTablet).toBe(false);
expect(props.hideOnMobile).toBe(false);
});
test('style carries blank/default box-model keys', () => {
const style = (MapEmbed as any).craft.props.style;
expect(style).toHaveProperty('marginTop');
expect(style).toHaveProperty('paddingTop');
expect(style.border).toBe('none');
expect(style.boxShadow).toBe('none');
expect(style.opacity).toBe('1');
});
});
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');
});
});
+26 -2
View File
@@ -8,11 +8,24 @@ interface MapEmbedProps {
zoom?: number;
height?: string;
style?: CSSProperties;
animation?: string;
animationDelay?: string;
hideOnDesktop?: boolean;
hideOnTablet?: boolean;
hideOnMobile?: boolean;
}
function buildMapUrl(address: string, zoom: number): string {
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> = ({
@@ -62,7 +75,18 @@ MapEmbed.craft = {
address: 'New York, NY',
zoom: 14,
height: '400px',
style: {},
style: {
marginTop: '', marginRight: '', marginBottom: '', marginLeft: '',
paddingTop: '', paddingRight: '', paddingBottom: '', paddingLeft: '',
border: 'none',
boxShadow: 'none',
opacity: '1',
},
animation: '',
animationDelay: '0',
hideOnDesktop: false,
hideOnTablet: false,
hideOnMobile: false,
},
rules: {
canDrag: () => true,
@@ -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)', () => {
test('embed params joined with literal & are HTML-entity-encoded in the emitted src attribute', () => {
// autoplay+muted+controls=false forces buildEmbedParams to concatenate
@@ -86,3 +118,75 @@ describe('VideoBlock.toHtml iframe src ampersand encoding (F-export review Minor
expect(srcMatch![1]).not.toMatch(/&(?!amp;)/);
});
});
describe('VideoBlock.toHtml size + aspect ratio (frame honors style props, not a hardcoded 16:9)', () => {
test('direct file: style.width flows to the wrapper, style.aspectRatio flows to the <video>', () => {
const { html } = toHtml({ videoUrl: 'https://example.com/clip.mp4', style: { width: '50%', aspectRatio: '4 / 3' } }, '');
expect(html).toMatch(/<div style="[^"]*width:50%[^"]*"/);
expect(html).toMatch(/<video[^>]*style="[^"]*aspect-ratio:4 \/ 3[^"]*"/);
});
test('direct file: no aspectRatio set -- no aspect-ratio declaration is forced onto the <video>', () => {
const { html } = toHtml({ videoUrl: 'https://example.com/clip.mp4' }, '');
const videoTag = html.match(/<video[^>]*>/)![0];
expect(videoTag).not.toContain('aspect-ratio');
});
test('YouTube/Vimeo: style.aspectRatio overrides the 16:9 default on the iframe container', () => {
const { html } = toHtml({ videoUrl: 'https://vimeo.com/123456789', style: { aspectRatio: '1 / 1' } }, '');
expect(html).toMatch(/<div[^>]*style="[^"]*aspect-ratio:1 \/ 1[^"]*"[^>]*><iframe/);
});
test('YouTube/Vimeo: defaults to 16 / 9 when no aspectRatio style is set', () => {
const { html } = toHtml({ videoUrl: 'https://vimeo.com/123456789' }, '');
expect(html).toMatch(/<div[^>]*style="[^"]*aspect-ratio:16 \/ 9[^"]*"[^>]*><iframe/);
});
});
describe('VideoBlock.toHtml poster + preload (file type)', () => {
test('poster attribute is emitted (escaped) and preload="metadata" is always present on a direct file', () => {
const { html } = toHtml({ videoUrl: 'https://example.com/clip.mp4', poster: 'https://example.com/poster.jpg' }, '');
expect(html).toContain('poster="https://example.com/poster.jpg"');
expect(html).toContain('preload="metadata"');
});
test('no poster prop -- no poster attribute is emitted, but preload="metadata" still is', () => {
const { html } = toHtml({ videoUrl: 'https://example.com/clip.mp4' }, '');
expect(html).not.toContain('poster=');
expect(html).toContain('preload="metadata"');
});
test('a malicious poster (javascript: scheme) is blocked by safeImageUrl', () => {
const { html } = toHtml({ videoUrl: 'https://example.com/clip.mp4', poster: 'javascript:alert(1)' }, '');
expect(html).not.toContain('javascript:');
});
test('a poster value cannot break out of the poster attribute', () => {
const malicious = 'https://example.com/x.jpg" onerror="alert(1)';
const { html } = toHtml({ videoUrl: 'https://example.com/clip.mp4', poster: malicious }, '');
expect(html).not.toContain('onerror="alert(1)"');
});
});
describe('VideoBlock.craft.props exposes the box-model/animation/visibility rollout', () => {
test('poster, animation, animationDelay, hideOnDesktop/Tablet/Mobile are present with blank/false defaults', () => {
const props = (VideoBlock as any).craft.props;
expect(props.poster).toBe('');
expect(props.animation).toBe('');
expect(props.animationDelay).toBe('0');
expect(props.hideOnDesktop).toBe(false);
expect(props.hideOnTablet).toBe(false);
expect(props.hideOnMobile).toBe(false);
});
test('style carries blank/default box-model + size/aspect keys', () => {
const style = (VideoBlock as any).craft.props.style;
expect(style).toHaveProperty('width');
expect(style).toHaveProperty('aspectRatio');
expect(style).toHaveProperty('marginTop');
expect(style).toHaveProperty('paddingTop');
expect(style.border).toBe('none');
expect(style.boxShadow).toBe('none');
expect(style.opacity).toBe('1');
});
});
+36 -11
View File
@@ -2,7 +2,7 @@ import React, { CSSProperties } from 'react';
import { useNode, Element, UserComponent } from '@craftjs/core';
import { Container } from '../layout/Container';
import { cssPropsToString } from '../../utils/style-helpers';
import { escapeAttr, safeUrl } from '../../utils/escape';
import { escapeAttr, safeUrl, safeImageUrl } from '../../utils/escape';
/* ---------- Types ---------- */
@@ -12,6 +12,7 @@ interface VideoBlockProps {
videoUrl?: string;
videoType?: VideoType;
embedUrl?: string;
poster?: string;
autoplay?: boolean;
muted?: boolean;
loop?: boolean;
@@ -22,6 +23,11 @@ interface VideoBlockProps {
innerMaxWidth?: string;
style?: CSSProperties;
children?: React.ReactNode;
animation?: string;
animationDelay?: string;
hideOnDesktop?: boolean;
hideOnTablet?: boolean;
hideOnMobile?: boolean;
}
/* ---------- URL detection ---------- */
@@ -120,6 +126,7 @@ export const VideoBlock: UserComponent<VideoBlockProps> = ({
videoUrl = '',
videoType: _videoTypeProp,
embedUrl: _embedUrlProp,
poster = '',
autoplay = false,
muted = true,
loop = false,
@@ -259,8 +266,7 @@ export const VideoBlock: UserComponent<VideoBlockProps> = ({
<div
style={{
position: 'relative',
paddingBottom: '56.25%',
height: 0,
aspectRatio: (style as any)?.aspectRatio || '16 / 9',
overflow: 'hidden',
borderRadius: (style as any)?.borderRadius || undefined,
}}
@@ -269,8 +275,7 @@ export const VideoBlock: UserComponent<VideoBlockProps> = ({
src={buildEmbedParams(embedUrl, { autoplay, muted, loop, controls })}
style={{
position: 'absolute',
top: 0,
left: 0,
inset: 0,
width: '100%',
height: '100%',
border: 'none',
@@ -284,14 +289,18 @@ export const VideoBlock: UserComponent<VideoBlockProps> = ({
{type === 'file' && (
<video
src={embedUrl}
poster={poster || undefined}
autoPlay={autoplay}
muted={muted}
loop={loop}
controls={controls}
preload="metadata"
playsInline
style={{
display: 'block',
width: '100%',
aspectRatio: (style as any)?.aspectRatio || undefined,
objectFit: 'cover',
borderRadius: (style as any)?.borderRadius || undefined,
}}
/>
@@ -310,6 +319,7 @@ VideoBlock.craft = {
videoUrl: '',
videoType: 'none',
embedUrl: '',
poster: '',
autoplay: false,
muted: true,
loop: false,
@@ -318,7 +328,20 @@ VideoBlock.craft = {
overlayColor: '#000000',
overlayOpacity: 50,
innerMaxWidth: '1200px',
style: {},
style: {
width: '',
aspectRatio: '',
marginTop: '', marginRight: '', marginBottom: '', marginLeft: '',
paddingTop: '', paddingRight: '', paddingBottom: '', paddingLeft: '',
border: 'none',
boxShadow: 'none',
opacity: '1',
},
animation: '',
animationDelay: '0',
hideOnDesktop: false,
hideOnTablet: false,
hideOnMobile: false,
},
rules: {
canDrag: () => true,
@@ -334,6 +357,7 @@ VideoBlock.craft = {
(VideoBlock as any).toHtml = (props: VideoBlockProps, childrenHtml: string) => {
const {
videoUrl = '',
poster = '',
autoplay = false,
muted = true,
loop: doLoop = false,
@@ -422,15 +446,13 @@ VideoBlock.craft = {
const iframeSrc = buildEmbedParams(embedUrl, { autoplay, muted, loop: doLoop, controls });
const containerStyle = cssPropsToString({
position: 'relative',
paddingBottom: '56.25%',
height: '0',
aspectRatio: (style as any)?.aspectRatio || '16 / 9',
overflow: 'hidden',
borderRadius: (style as any)?.borderRadius || undefined,
});
const iframeStyle = cssPropsToString({
position: 'absolute',
top: '0',
left: '0',
inset: '0',
width: '100%',
height: '100%',
border: 'none',
@@ -447,13 +469,16 @@ VideoBlock.craft = {
if (doLoop) vidAttrs.push('loop');
if (controls) vidAttrs.push('controls');
vidAttrs.push('playsinline');
const posterAttr = poster ? ` poster="${escapeAttr(safeImageUrl(poster))}"` : '';
const vidStyle = cssPropsToString({
display: 'block',
width: '100%',
aspectRatio: (style as any)?.aspectRatio || undefined,
objectFit: 'cover',
borderRadius: (style as any)?.borderRadius || undefined,
});
return {
html: `<div${wrapperStyle ? ` style="${wrapperStyle}"` : ''}><video src="${escapeAttr(safeUrl(embedUrl))}" ${vidAttrs.join(' ')}${vidStyle ? ` style="${vidStyle}"` : ''}></video></div>`,
html: `<div${wrapperStyle ? ` style="${wrapperStyle}"` : ''}><video src="${escapeAttr(safeUrl(embedUrl))}"${posterAttr} preload="metadata" ${vidAttrs.join(' ')}${vidStyle ? ` style="${vidStyle}"` : ''}></video></div>`,
};
};
@@ -0,0 +1,38 @@
import { describe, test, expect } from 'vitest';
import { Accordion } from './Accordion';
const toHtml = (Accordion as any).toHtml;
const items = [
{ title: 'Q1', content: 'A1', isOpen: true },
{ title: 'Q2', content: 'A2', isOpen: false },
];
describe('Accordion.toHtml basic export', () => {
test('renders a <details> per item with the open attribute honored', () => {
const { html } = toHtml({ items }, '');
const detailsBlocks = html.match(/<details[^>]*>/g) || [];
expect(detailsBlocks.length).toBe(2);
expect(detailsBlocks[0]).toContain(' open');
expect(detailsBlocks[1]).not.toContain(' open');
});
test('headerBg/headerColor/contentBg/borderColor emit into the panel styles', () => {
const { html } = toHtml({ items, headerBg: '#111111', headerColor: '#222222', contentBg: '#333333', borderColor: '#444444' }, '');
expect(html).toContain('#111111');
expect(html).toContain('#222222');
expect(html).toContain('#333333');
expect(html).toContain('#444444');
});
});
describe('Accordion.craft.props includes the box-model/animation/visibility rollout props', () => {
test('animation, animationDelay, and all 3 hideOn* flags are declared (blank/false defaults)', () => {
const props = (Accordion as any).craft.props;
expect(props).toHaveProperty('animation', '');
expect(props).toHaveProperty('animationDelay', '');
expect(props).toHaveProperty('hideOnDesktop', false);
expect(props).toHaveProperty('hideOnTablet', false);
expect(props).toHaveProperty('hideOnMobile', false);
});
});
+17 -5
View File
@@ -1,7 +1,7 @@
import React, { CSSProperties, useState } from 'react';
import { useNode, UserComponent } from '@craftjs/core';
import { cssPropsToString } from '../../utils/style-helpers';
import { escapeHtml, escapeAttr } from '../../utils/escape';
import { escapeHtml, escapeAttr, cssValue } from '../../utils/escape';
interface AccordionItem {
title: string;
@@ -17,6 +17,11 @@ interface AccordionProps {
contentBg?: string;
borderColor?: string;
anchorId?: string;
animation?: string;
animationDelay?: string;
hideOnDesktop?: boolean;
hideOnTablet?: boolean;
hideOnMobile?: boolean;
}
const defaultItems: AccordionItem[] = [
@@ -135,6 +140,11 @@ Accordion.craft = {
contentBg: '#ffffff',
borderColor: '#e2e8f0',
anchorId: '',
animation: '',
animationDelay: '',
hideOnDesktop: false,
hideOnTablet: false,
hideOnMobile: false,
},
rules: {
canDrag: () => true,
@@ -151,10 +161,12 @@ Accordion.craft = {
...props.style,
});
const idAttr = props.anchorId ? ` id="${escapeAttr(props.anchorId)}"` : '';
const headerBg = props.headerBg || '#f8fafc';
const headerColor = props.headerColor || '#18181b';
const contentBg = props.contentBg || '#ffffff';
const borderColor = props.borderColor || '#e2e8f0';
// Sanitized -- raw string-interpolation sinks in the <details>/<summary>
// style attributes below.
const headerBg = cssValue(props.headerBg) || '#f8fafc';
const headerColor = cssValue(props.headerColor) || '#18181b';
const contentBg = cssValue(props.contentBg) || '#ffffff';
const borderColor = cssValue(props.borderColor) || '#e2e8f0';
const items = props.items || defaultItems;
const panels = items.map((item, i) => {
@@ -0,0 +1,37 @@
import { describe, test, expect } from 'vitest';
import { CTASection } from './CTASection';
const toHtml = (CTASection as any).toHtml;
describe('CTASection.toHtml basic export', () => {
test('renders heading, description, and CTA buttons', () => {
const { html } = toHtml({ heading: 'Hi', description: 'Sub', ctas: [{ text: 'Go', href: '#', variant: 'primary' }] }, '');
expect(html).toContain('Hi');
expect(html).toContain('Sub');
expect(html).toContain('Go');
});
test('box-model style props (margin/padding/border/boxShadow/opacity) flow through to the section style=""', () => {
const { html } = toHtml({
heading: 'Hi',
description: 'Sub',
style: { marginTop: '10px', paddingLeft: '5px', border: '1px solid #000', boxShadow: '0 1px 2px rgba(0,0,0,0.1)', opacity: '0.5' },
}, '');
expect(html).toContain('margin-top:10px');
expect(html).toContain('padding-left:5px');
expect(html).toContain('border:1px solid #000');
expect(html).toContain('box-shadow:0 1px 2px rgba(0,0,0,0.1)');
expect(html).toContain('opacity:0.5');
});
});
describe('CTASection.craft.props includes the box-model/animation/visibility rollout props', () => {
test('animation, animationDelay, and all 3 hideOn* flags are declared (blank/false defaults)', () => {
const props = (CTASection as any).craft.props;
expect(props).toHaveProperty('animation', '');
expect(props).toHaveProperty('animationDelay', '');
expect(props).toHaveProperty('hideOnDesktop', false);
expect(props).toHaveProperty('hideOnTablet', false);
expect(props).toHaveProperty('hideOnMobile', false);
});
});
@@ -14,6 +14,11 @@ interface CTASectionProps {
gradient?: string;
anchorId?: string;
style?: CSSProperties;
animation?: string;
animationDelay?: string;
hideOnDesktop?: boolean;
hideOnTablet?: boolean;
hideOnMobile?: boolean;
}
const defaultGradient = 'linear-gradient(135deg, #2563eb 0%, #7c3aed 100%)';
@@ -83,6 +88,11 @@ CTASection.craft = {
gradient: defaultGradient,
anchorId: '',
style: {},
animation: '',
animationDelay: '',
hideOnDesktop: false,
hideOnTablet: false,
hideOnMobile: false,
},
rules: {
canDrag: () => true,
@@ -0,0 +1,37 @@
import { describe, test, expect } from 'vitest';
import { CallToAction } from './CallToAction';
const toHtml = (CallToAction as any).toHtml;
describe('CallToAction.toHtml basic export', () => {
test('renders heading, description, and CTA buttons', () => {
const { html } = toHtml({ heading: 'Hi', description: 'Sub', ctas: [{ text: 'Go', href: '#', variant: 'primary' }] }, '');
expect(html).toContain('Hi');
expect(html).toContain('Sub');
expect(html).toContain('Go');
});
test('box-model style props (margin/padding/border/boxShadow/opacity) flow through to the section style=""', () => {
const { html } = toHtml({
heading: 'Hi',
description: 'Sub',
style: { marginBottom: '12px', paddingRight: '6px', border: '2px dashed #333', boxShadow: '0 4px 8px rgba(0,0,0,0.12)', opacity: '0.75' },
}, '');
expect(html).toContain('margin-bottom:12px');
expect(html).toContain('padding-right:6px');
expect(html).toContain('border:2px dashed #333');
expect(html).toContain('box-shadow:0 4px 8px rgba(0,0,0,0.12)');
expect(html).toContain('opacity:0.75');
});
});
describe('CallToAction.craft.props includes the box-model/animation/visibility rollout props', () => {
test('animation, animationDelay, and all 3 hideOn* flags are declared (blank/false defaults)', () => {
const props = (CallToAction as any).craft.props;
expect(props).toHaveProperty('animation', '');
expect(props).toHaveProperty('animationDelay', '');
expect(props).toHaveProperty('hideOnDesktop', false);
expect(props).toHaveProperty('hideOnTablet', false);
expect(props).toHaveProperty('hideOnMobile', false);
});
});
+14 -2
View File
@@ -2,7 +2,7 @@ import React, { CSSProperties } from 'react';
import { useNode, UserComponent } from '@craftjs/core';
import { cssPropsToString } from '../../utils/style-helpers';
import { CtaButton, normalizeCtas, ctaInlineStyle, ctasToHtml } from './_cta-helpers';
import { escapeHtml, escapeAttr } from '../../utils/escape';
import { escapeHtml, escapeAttr, cssValue } from '../../utils/escape';
interface CallToActionProps {
heading?: string;
@@ -21,6 +21,11 @@ interface CallToActionProps {
buttonColor?: string;
anchorId?: string;
style?: CSSProperties;
animation?: string;
animationDelay?: string;
hideOnDesktop?: boolean;
hideOnTablet?: boolean;
hideOnMobile?: boolean;
}
const defaultGradient = 'linear-gradient(135deg, #2563eb 0%, #7c3aed 100%)';
@@ -131,6 +136,11 @@ CallToAction.craft = {
textColor: '#ffffff',
buttonColor: '#ffffff',
style: {},
animation: '',
animationDelay: '',
hideOnDesktop: false,
hideOnTablet: false,
hideOnMobile: false,
},
rules: {
canDrag: () => true,
@@ -144,7 +154,9 @@ CallToAction.craft = {
(CallToAction as any).toHtml = (props: CallToActionProps, _childrenHtml: string) => {
const bgType = props.bgType || 'gradient';
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 isButtonDark = buttonColor === '#ffffff' || buttonColor === '#f8fafc';
const buttonTextColor = isButtonDark ? '#18181b' : '#ffffff';
@@ -91,3 +91,94 @@ describe('ContentSlider.toHtml autoplay silences aria-live and is pausable (F-ex
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\);/);
});
});
describe('ContentSlider.toHtml box-model styles (margin/padding/border/shadow/opacity)', () => {
test('margin/padding/border/box-shadow/opacity all flow into the section style attribute', () => {
const { html } = toHtml(
{
slides,
style: {
marginBottom: '24px',
paddingTop: '8px',
border: '3px dashed #00ff00',
boxShadow: '0 10px 24px rgba(0,0,0,0.18)',
opacity: '0.75',
},
},
''
);
expect(html).toContain('margin-bottom:24px');
expect(html).toContain('padding-top:8px');
expect(html).toContain('border:3px dashed #00ff00');
expect(html).toContain('box-shadow:0 10px 24px rgba(0,0,0,0.18)');
expect(html).toContain('opacity:0.75');
});
});
describe('ContentSlider.craft.props exposes the animation/visibility rollout', () => {
test('animation, animationDelay, hideOnDesktop/Tablet/Mobile are present with blank/false defaults', () => {
const props = (ContentSlider as any).craft.props;
expect(props.animation).toBe('');
expect(props.animationDelay).toBe('0');
expect(props.hideOnDesktop).toBe(false);
expect(props.hideOnTablet).toBe(false);
expect(props.hideOnMobile).toBe(false);
});
test('style carries blank/default box-model keys', () => {
const style = (ContentSlider as any).craft.props.style;
expect(style).toHaveProperty('marginTop');
expect(style).toHaveProperty('paddingTop');
expect(style.border).toBe('none');
expect(style.boxShadow).toBe('none');
expect(style.opacity).toBe('1');
});
});
@@ -1,7 +1,7 @@
import React, { CSSProperties, useState, useEffect, useRef, useCallback } from 'react';
import { useNode, UserComponent } from '@craftjs/core';
import { cssPropsToString } from '../../utils/style-helpers';
import { escapeHtml, escapeAttr, safeUrl, scopeId } from '../../utils/escape';
import { escapeHtml, escapeAttr, safeUrl, safeImageUrl, scopeId, cssValue } from '../../utils/escape';
interface Slide {
type: 'image' | 'content';
@@ -21,6 +21,11 @@ interface ContentSliderProps {
showArrows?: boolean;
height?: string;
style?: CSSProperties;
animation?: string;
animationDelay?: string;
hideOnDesktop?: boolean;
hideOnTablet?: boolean;
hideOnMobile?: boolean;
}
const defaultSlides: Slide[] = [
@@ -227,7 +232,18 @@ ContentSlider.craft = {
showDots: true,
showArrows: true,
height: '400px',
style: {},
style: {
marginTop: '', marginRight: '', marginBottom: '', marginLeft: '',
paddingTop: '', paddingRight: '', paddingBottom: '', paddingLeft: '',
border: 'none',
boxShadow: 'none',
opacity: '1',
},
animation: '',
animationDelay: '0',
hideOnDesktop: false,
hideOnTablet: false,
hideOnMobile: false,
},
rules: {
canDrag: () => true,
@@ -250,11 +266,24 @@ ContentSlider.craft = {
} = props;
const items = slides.length > 0 ? slides : defaultSlides;
// Number() coercion: `interval` is declared `number` in TS but is NOT
// type-checked at runtime -- it arrives raw via the AI `update_props`
// path or a deserialized saved-state blob and is interpolated directly
// into the inline <script>'s `setInterval(fn, ${interval})` call below as
// a bare JS numeral (no quotes around it). A string like
// `5000);alert(1);//` would previously close the setInterval() call and
// splice arbitrary JS into the page's own <script> tag -- worse than an
// HTML attribute breakout, since it runs unconditionally on page load.
// Number() of anything non-numeric collapses safely to NaN, so we fall
// back to the 5000ms default rather than ever interpolating a
// non-numeral.
const intervalNum = Number(interval);
const safeInterval = Number.isFinite(intervalNum) && intervalNum > 0 ? intervalNum : 5000;
// Deterministic AND unique id, scoped on the Craft node id, for this
// slider's slide/dot element ids and inline-script globals -- so two
// ContentSlider instances (e.g. both left at default slides) don't
// collide and end up driving each other's rotation.
const uid = scopeId(nodeId, JSON.stringify(items) + interval, 'cs');
const uid = scopeId(nodeId, JSON.stringify(items) + safeInterval, 'cs');
const sectionStyle = cssPropsToString({
position: 'relative',
@@ -266,11 +295,14 @@ ContentSlider.craft = {
const slidesHtml = items.map((slide, i) => {
const hasBgImage = slide.imageSrc;
// Sanitized -- slide.bgColor is a per-slide raw string-interpolation
// sink (a malicious value could break out of the style="..." attribute).
const safeBgColor = cssValue(slide.bgColor) || '#3b82f6';
const bgStyle = hasBgImage
? `background-image:url('${escapeAttr(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')
? `background-image:${slide.bgColor}`
: `background-color:${slide.bgColor || '#3b82f6'}`;
? `background-image:${safeBgColor}`
: `background-color:${safeBgColor}`;
const contentParts: string[] = [];
if (slide.heading) {
@@ -336,7 +368,7 @@ ContentSlider.craft = {
window["${uid}_prev"]=function(){ markManualNav(); show(current-1); };
${autoplayActive ? `
var timer=null;
function start(){ if(!timer && document.visibilityState!=="hidden"){ timer=setInterval(function(){show(current+1);},${interval}); } }
function start(){ if(!timer && document.visibilityState!=="hidden"){ timer=setInterval(function(){show(current+1);},${safeInterval}); } }
function stop(){ if(timer){ clearInterval(timer); timer=null; } }
var root=document.getElementById(uid);
if(root){
@@ -62,3 +62,14 @@ describe('Countdown.toHtml script nit: ticking interval stops at zero', () => {
expect(html).toMatch(/if\s*\(\s*target\s*-\s*Date\.now\(\)\s*>\s*0\s*\)\s*\{/);
});
});
describe('Countdown.craft.props includes the box-model/animation/visibility rollout props', () => {
test('animation, animationDelay, and all 3 hideOn* flags are declared (blank/false defaults)', () => {
const props = (Countdown as any).craft.props;
expect(props).toHaveProperty('animation', '');
expect(props).toHaveProperty('animationDelay', '');
expect(props).toHaveProperty('hideOnDesktop', false);
expect(props).toHaveProperty('hideOnTablet', false);
expect(props).toHaveProperty('hideOnMobile', false);
});
});
+15 -3
View File
@@ -1,7 +1,7 @@
import React, { CSSProperties, useEffect, useState } from 'react';
import { useNode, UserComponent } from '@craftjs/core';
import { cssPropsToString } from '../../utils/style-helpers';
import { escapeHtml, escapeAttr, scopeId } from '../../utils/escape';
import { escapeHtml, escapeAttr, scopeId, cssValue } from '../../utils/escape';
interface CountdownProps {
targetDate?: string;
@@ -11,6 +11,11 @@ interface CountdownProps {
labelColor?: string;
bgColor?: string;
anchorId?: string;
animation?: string;
animationDelay?: string;
hideOnDesktop?: boolean;
hideOnTablet?: boolean;
hideOnMobile?: boolean;
}
interface TimeLeft {
@@ -139,6 +144,11 @@ Countdown.craft = {
labelColor: 'rgba(255,255,255,0.7)',
bgColor: '#18181b',
anchorId: '',
animation: '',
animationDelay: '',
hideOnDesktop: false,
hideOnTablet: false,
hideOnMobile: false,
},
rules: {
canDrag: () => true,
@@ -154,10 +164,12 @@ Countdown.craft = {
targetDate = DEFAULT_TARGET,
heading = 'Coming Soon',
style = {},
digitColor = '#ffffff',
labelColor = 'rgba(255,255,255,0.7)',
bgColor = '#18181b',
} = props;
// Sanitized -- raw string-interpolation sinks in the heading/digit/label
// style attributes below.
const digitColor = cssValue(props.digitColor) || '#ffffff';
const labelColor = cssValue(props.labelColor) || 'rgba(255,255,255,0.7)';
const sectionStyle = cssPropsToString({
padding: '60px 20px',
@@ -0,0 +1,34 @@
import { describe, test, expect } from 'vitest';
import { FeaturesGrid } from './FeaturesGrid';
const toHtml = (FeaturesGrid as any).toHtml;
describe('FeaturesGrid.toHtml image sink uses safeImageUrl (data:image/svg+xml allowed)', () => {
test('feat.image as a data:image/svg+xml value emits a non-empty <img src>', () => {
const svgDataUri = 'data:image/svg+xml,%3Csvg%2F%3E';
const features = [
{ title: 'Feature', description: 'Desc', icon: '⚡', image: svgDataUri, imageAlt: 'alt' },
];
const { html } = toHtml({ features }, '');
expect(html).toContain(`<img src="${svgDataUri}"`);
});
test('feat.buttonUrl stays on safeUrl (data:image/svg+xml blocked as a navigation target)', () => {
const features = [
{ title: 'Feature', description: 'Desc', icon: '⚡', buttonText: 'Go', buttonUrl: 'data:image/svg+xml,<svg onload=alert(1)>' },
];
const { html } = toHtml({ features }, '');
expect(html).toMatch(/<a href=""/);
});
});
describe('FeaturesGrid.craft.props includes the box-model/animation/visibility rollout props', () => {
test('animation, animationDelay, and all 3 hideOn* flags are declared (blank/false defaults)', () => {
const props = (FeaturesGrid as any).craft.props;
expect(props).toHaveProperty('animation', '');
expect(props).toHaveProperty('animationDelay', '');
expect(props).toHaveProperty('hideOnDesktop', false);
expect(props).toHaveProperty('hideOnTablet', false);
expect(props).toHaveProperty('hideOnMobile', false);
});
});
+12 -2
View File
@@ -1,7 +1,7 @@
import React, { CSSProperties } from 'react';
import { useNode, UserComponent } from '@craftjs/core';
import { cssPropsToString } from '../../utils/style-helpers';
import { escapeHtml, escapeAttr, safeUrl } from '../../utils/escape';
import { escapeHtml, escapeAttr, safeUrl, safeImageUrl } from '../../utils/escape';
interface FeatureItem {
title: string;
@@ -17,6 +17,11 @@ interface FeaturesGridProps {
features?: FeatureItem[];
style?: CSSProperties;
anchorId?: string;
animation?: string;
animationDelay?: string;
hideOnDesktop?: boolean;
hideOnTablet?: boolean;
hideOnMobile?: boolean;
}
// Keys image/imageAlt/buttonText/buttonUrl are present (blank) on the defaults so
@@ -98,6 +103,11 @@ FeaturesGrid.craft = {
features: defaultFeatures,
style: { backgroundColor: '#ffffff' },
anchorId: '',
animation: '',
animationDelay: '',
hideOnDesktop: false,
hideOnTablet: false,
hideOnMobile: false,
},
rules: {
canDrag: () => true,
@@ -116,7 +126,7 @@ FeaturesGrid.craft = {
const idAttr = props.anchorId ? ` id="${escapeAttr(props.anchorId)}"` : '';
const cards = (props.features || defaultFeatures).map((feat) => {
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>`;
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>`
@@ -3,6 +3,73 @@ import { Gallery } from './Gallery';
const toHtml = (Gallery as any).toHtml;
describe('Gallery.toHtml columns + lightbox controls (previously unexposed props)', () => {
test('columns drives the grid-template-columns repeat count', () => {
const { html } = toHtml({ images: [{ src: '/a.jpg', alt: 'a' }], columns: 5 }, '');
expect(html).toContain('grid-template-columns:repeat(5,1fr)');
});
test('lightbox=true adds the delegated-listener overlay markup (columns unaffected)', () => {
const { html } = toHtml({ images: [{ src: '/a.jpg', alt: 'a' }], columns: 4, lightbox: true }, '');
expect(html).toContain('grid-template-columns:repeat(4,1fr)');
expect(html).toContain('role="dialog"');
});
test('a non-numeric columns value falls back safely (Number() coercion, not NaN in the template)', () => {
const { html } = toHtml({ images: [{ src: '/a.jpg' }], columns: 'not-a-number' as any }, '');
expect(html).toContain('grid-template-columns:repeat(3,1fr)');
});
});
describe('Gallery.toHtml box-model styles (margin/padding/border/shadow/opacity)', () => {
test('margin/padding/border/box-shadow/opacity all flow into the section style attribute', () => {
const { html } = toHtml(
{
images: [{ src: '/a.jpg', alt: 'a' }],
style: {
marginTop: '20px',
paddingLeft: '12px',
border: '1px solid #333333',
boxShadow: '0 1px 2px rgba(0,0,0,0.08)',
opacity: '0.9',
},
},
''
);
expect(html).toContain('margin-top:20px');
expect(html).toContain('padding-left:12px');
expect(html).toContain('border:1px solid #333333');
expect(html).toContain('box-shadow:0 1px 2px rgba(0,0,0,0.08)');
expect(html).toContain('opacity:0.9');
});
});
describe('Gallery.craft.props exposes columns/lightbox + the animation/visibility rollout', () => {
test('columns and lightbox have their existing defaults', () => {
const props = (Gallery as any).craft.props;
expect(props.columns).toBe(3);
expect(props.lightbox).toBe(false);
});
test('animation, animationDelay, hideOnDesktop/Tablet/Mobile are present with blank/false defaults', () => {
const props = (Gallery as any).craft.props;
expect(props.animation).toBe('');
expect(props.animationDelay).toBe('0');
expect(props.hideOnDesktop).toBe(false);
expect(props.hideOnTablet).toBe(false);
expect(props.hideOnMobile).toBe(false);
});
test('style carries blank/default box-model keys', () => {
const style = (Gallery as any).craft.props.style;
expect(style).toHaveProperty('marginTop');
expect(style).toHaveProperty('paddingTop');
expect(style.border).toBe('none');
expect(style.boxShadow).toBe('none');
expect(style.opacity).toBe('1');
});
});
describe('Gallery.toHtml lightbox uses a delegated listener, not per-item onclick (A4.3)', () => {
test('no per-item inline onclick with interpolated src', () => {
const { html } = toHtml({ images: [{ src: '/a.jpg', alt: 'a' }], lightbox: true }, '');
@@ -93,3 +160,59 @@ describe('Gallery.toHtml deterministic + unique scope ids (thread node id, no Ma
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\(\)/);
});
});
+59 -8
View File
@@ -1,7 +1,7 @@
import React, { CSSProperties } from 'react';
import { useNode, UserComponent } from '@craftjs/core';
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 {
src: string;
@@ -15,6 +15,11 @@ interface GalleryProps {
gap?: string;
style?: CSSProperties;
lightbox?: boolean;
animation?: string;
animationDelay?: string;
hideOnDesktop?: boolean;
hideOnTablet?: boolean;
hideOnMobile?: boolean;
}
const placeholderSvg = (index: number) => {
@@ -112,8 +117,20 @@ Gallery.craft = {
images: defaultImages,
columns: 3,
gap: '16px',
style: { backgroundColor: '#ffffff' },
style: {
backgroundColor: '#ffffff',
marginTop: '', marginRight: '', marginBottom: '', marginLeft: '',
paddingTop: '', paddingRight: '', paddingBottom: '', paddingLeft: '',
border: 'none',
boxShadow: 'none',
opacity: '1',
},
lightbox: false,
animation: '',
animationDelay: '0',
hideOnDesktop: false,
hideOnTablet: false,
hideOnMobile: false,
},
rules: {
canDrag: () => true,
@@ -130,8 +147,14 @@ Gallery.craft = {
...props.style,
});
const images = props.images || defaultImages;
const columns = props.columns || 3;
const gap = props.gap || '16px';
// Number() coercion: `columns` is a raw string-interpolation sink into the
// grid style attribute below (repeat(${columns},1fr)) -- a non-numeric
// (e.g. hand-crafted/AI-generated tree) value would otherwise be able to
// break out; Number() of anything non-numeric collapses safely to NaN.
const columns = Number(props.columns) || 3;
// Sanitized -- gap is a raw string-interpolation sink into the grid style
// attribute below.
const gap = cssValue(props.gap) || '16px';
const lightbox = props.lightbox || false;
// Deterministic AND unique id, scoped on the Craft node id, for this
@@ -148,10 +171,10 @@ Gallery.craft = {
// inline onclick with an interpolated src -- a single delegated click
// listener below reads it, so a src containing a quote can't break out
// of a per-item event-handler string.
const lbAttr = lightbox ? ` data-lb-src="${escapeAttr(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';
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}
</div>`;
}).join('\n ');
@@ -160,16 +183,37 @@ Gallery.craft = {
let gridIdAttr = '';
if (lightbox) {
gridIdAttr = ` id="${galleryId}_grid"`;
// M-2: focus management for the lightbox dialog.
// - OPEN: stash `document.activeElement` (the thumbnail that triggered
// the open) in a module-scoped var, then move focus onto the close
// button -- so a screen-reader/keyboard user lands inside the dialog
// instead of focus staying on (or silently falling back to <body>)
// behind the now-visible overlay.
// - Tab trap: while the overlay is open, every Tab keypress is
// intercepted and refocuses the close button (the dialog's only
// focusable control besides Escape/click-to-close), so focus can
// never wander out into the page content hidden behind the overlay.
// - CLOSE (Escape, backdrop click, or the close button): restore focus
// to the element stashed on open.
lightboxHtml = `
<div id="${galleryId}_overlay" role="dialog" aria-modal="true" aria-label="Image preview" onclick="${galleryId}_close()" style="display:none;position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,0.9);z-index:9999;justify-content:center;align-items:center;cursor:pointer">
<button type="button" id="${galleryId}_closebtn" aria-label="Close preview" tabindex="-1" onclick="event.stopPropagation();${galleryId}_close()" style="position:absolute;top:16px;right:16px;width:36px;height:36px;border-radius:50%;border:none;background:rgba(255,255,255,0.15);color:#ffffff;font-size:20px;line-height:1;cursor:pointer;display:flex;align-items:center;justify-content:center">&times;</button>
<img id="${galleryId}_img" src="" alt="" style="max-width:90%;max-height:90%;object-fit:contain;border-radius:8px" />
</div>
<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){
${galleryId}_lastFocus = document.activeElement;
var o = document.getElementById('${galleryId}_overlay');
document.getElementById('${galleryId}_img').src = src;
o.style.display = 'flex';
var c = document.getElementById('${galleryId}_closebtn');
if(c) c.focus();
}
document.getElementById('${galleryId}_grid').addEventListener('click', function(e){
var t = e.target.closest('[data-lb-src]');
@@ -184,7 +228,14 @@ document.getElementById('${galleryId}_grid').addEventListener('keydown', functio
${galleryId}_open(t.getAttribute('data-lb-src'));
});
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>`;
}
@@ -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 { cssPropsToString } from '../../utils/style-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 {
heading?: string;
@@ -220,7 +220,8 @@ HeroSimple.craft = {
let overlayHtml = '';
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 = '';
@@ -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>`;
}
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 ctas = normalizeCtas(props);
@@ -239,12 +246,13 @@ HeroSimple.craft = {
});
const idAttr = props.anchorId ? ` id="${escapeAttr(props.anchorId)}"` : '';
const heroTextColor = cssValue(props.textColor) || '#fff';
return {
html: `<section${idAttr} style="${sectionStyle}">
${videoHtml}${overlayHtml}
<div style="max-width:800px;width:100%;position:relative;z-index:2;text-align:${textAlign}">
<h1 style="font-size:48px;font-weight:700;color:${props.textColor || '#fff'};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>
<h1 style="font-size:48px;font-weight:700;color:${heroTextColor};margin-bottom:16px;line-height:1.2">${escapeHtml(props.heading || '')}</h1>
<p style="font-size:20px;color:${heroTextColor};opacity:0.85;margin-bottom:32px;line-height:1.6;white-space:pre-line">${escapeHtml(props.subtitle || '')}</p>
<div style="display:flex;gap:12px;justify-content:${justifyBtn};flex-wrap:wrap">${buttonsHtml}</div>
</div>
</section>`,
@@ -47,3 +47,40 @@ describe('NumberCounter.toHtml deterministic + unique scope ids (thread node id,
expect(wrapId1).not.toBe(wrapId2);
});
});
describe('NumberCounter.toHtml counter.number is NOT runtime-type-checked -- must be sanitized before it reaches data-target', () => {
test('a malicious counter.number cannot break out of the data-target attribute to inject a <script> tag', () => {
const malicious = [
{ number: '150"><script>alert(1)</script>', suffix: '+', label: 'Evil' },
];
const { html } = toHtml({ counters: malicious }, '', 'node-nc-evil1');
expect(html).not.toContain('<script>alert(1)</script>');
expect(html).not.toContain('"><script>');
});
test('a malicious counter.number cannot break out of the data-target attribute to inject an onmouseover handler', () => {
const malicious = [
{ number: '150" onmouseover="alert(1)', suffix: '+', label: 'Evil' },
];
const { html } = toHtml({ counters: malicious }, '', 'node-nc-evil2');
expect(html).not.toContain('onmouseover=');
expect(html).not.toMatch(/data-target="150" onmouseover/);
});
test('normal numeric counter.number values still render as data-target="150"', () => {
const normal = [{ number: 150, suffix: '+', label: 'Projects' }];
const { html } = toHtml({ counters: normal }, '', 'node-nc-normal');
expect(html).toContain('data-target="150"');
});
});
describe('NumberCounter.craft.props includes the box-model/animation/visibility rollout props', () => {
test('animation, animationDelay, and all 3 hideOn* flags are declared (blank/false defaults)', () => {
const props = (NumberCounter as any).craft.props;
expect(props).toHaveProperty('animation', '');
expect(props).toHaveProperty('animationDelay', '');
expect(props).toHaveProperty('hideOnDesktop', false);
expect(props).toHaveProperty('hideOnTablet', false);
expect(props).toHaveProperty('hideOnMobile', false);
});
});
@@ -1,7 +1,7 @@
import React, { CSSProperties } from 'react';
import { useNode, UserComponent } from '@craftjs/core';
import { cssPropsToString } from '../../utils/style-helpers';
import { escapeHtml, escapeAttr, scopeId } from '../../utils/escape';
import { escapeHtml, escapeAttr, scopeId, cssValue } from '../../utils/escape';
interface Counter {
number: number;
@@ -16,6 +16,11 @@ interface NumberCounterProps {
labelColor?: string;
numberSize?: string;
style?: CSSProperties;
animation?: string;
animationDelay?: string;
hideOnDesktop?: boolean;
hideOnTablet?: boolean;
hideOnMobile?: boolean;
}
const defaultCounters: Counter[] = [
@@ -99,6 +104,11 @@ NumberCounter.craft = {
labelColor: '#6b7280',
numberSize: '48px',
style: { backgroundColor: '#ffffff' },
animation: '',
animationDelay: '',
hideOnDesktop: false,
hideOnTablet: false,
hideOnMobile: false,
},
rules: {
canDrag: () => true,
@@ -112,12 +122,17 @@ NumberCounter.craft = {
(NumberCounter as any).toHtml = (props: NumberCounterProps, _childrenHtml: string, nodeId?: string) => {
const {
counters = defaultCounters,
columns = 4,
numberColor = '#3b82f6',
labelColor = '#6b7280',
numberSize = '48px',
style = {},
} = props;
// Number() coercion: `columns` is a raw string-interpolation sink into the
// grid style attribute below (repeat(${columns},1fr)); Number() of
// anything non-numeric collapses safely to NaN instead of breaking out.
const columns = Number(props.columns) || 4;
// Sanitized -- raw string-interpolation sinks in the counter/label spans
// below.
const numberColor = cssValue(props.numberColor) || '#3b82f6';
const labelColor = cssValue(props.labelColor) || '#6b7280';
const numberSize = cssValue(props.numberSize) || '48px';
const items = counters.length > 0 ? counters : defaultCounters;
@@ -135,8 +150,20 @@ NumberCounter.craft = {
});
const countersHtml = items.map((counter, i) => {
// Number() coercion + escapeAttr: `counter.number` is declared `number`
// per-item inside an array prop, but is NOT type-checked at runtime --
// it arrives raw via the AI `update_props` path or a deserialized
// saved-state blob and was previously interpolated straight into this
// data-target attribute, letting a string like `150"><script>...`
// break out of the attribute and inject markup. Number() collapses any
// non-numeric value safely to NaN (then 0), and escapeAttr is kept as
// defense-in-depth in case Number()'s string coercion output ever
// contains a stray character (it can't today, but the sink should never
// rely solely on the coercion).
const numberVal = Number(counter.number);
const safeNumber = Number.isFinite(numberVal) ? numberVal : 0;
return `<div style="display:flex;flex-direction:column;align-items:center;gap:8px">
<span id="${uid}_n${i}" data-target="${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>
</div>`;
}).join('\n ');
@@ -0,0 +1,105 @@
import { describe, test, expect } from 'vitest';
import { PricingTable } from './PricingTable';
const toHtml = (PricingTable as any).toHtml;
const plans = [
{ name: 'Basic', price: '$9', period: '/month', features: ['Feature A'], buttonText: 'Buy', buttonHref: '#', isFeatured: false },
{ name: 'Pro', price: '$29', period: '/month', features: ['Feature B'], buttonText: 'Buy', buttonHref: '#', isFeatured: true },
];
describe('PricingTable.toHtml regular-card color overrides (previously hard-coded, now real props)', () => {
test('cardBg emits as the non-featured card background', () => {
const { html } = toHtml({ plans, cardBg: '#f0f0f0' }, '');
expect(html).toContain('background-color:#f0f0f0');
});
test('textColor emits as the non-featured heading/price color', () => {
const { html } = toHtml({ plans, textColor: '#123456' }, '');
expect(html).toContain('color:#123456');
});
test('subColor emits as the non-featured period text color', () => {
const { html } = toHtml({ plans, subColor: '#abcdef' }, '');
expect(html).toContain('color:#abcdef');
});
test('featColor emits as the non-featured feature list text color', () => {
const { html } = toHtml({ plans, featColor: '#334455' }, '');
expect(html).toContain('color:#334455');
});
test('checkColor emits as the non-featured bullet color', () => {
const { html } = toHtml({ plans, checkColor: '#00ff00' }, '');
expect(html).toContain('color:#00ff00');
});
test('btnBg/btnColor emit as the non-featured button colors', () => {
const { html } = toHtml({ plans, btnBg: '#111111', btnColor: '#eeeeee' }, '');
expect(html).toContain('background-color:#111111');
expect(html).toContain('color:#eeeeee');
});
test('unset overrides fall back to the exact prior literals (backward compatible)', () => {
const { html } = toHtml({ plans }, '');
// Regular (non-featured) card literals unchanged from before these props existed.
expect(html).toContain('background-color:#ffffff');
expect(html).toContain('color:#18181b');
expect(html).toContain('color:#64748b');
expect(html).toContain('color:#4b5563');
expect(html).toContain('color:#10b981');
});
test('unset btnBg falls back to featuredBg (original derivation)', () => {
const { html } = toHtml({ plans, featuredBg: '#654321' }, '');
expect(html).toContain('background-color:#654321');
});
});
describe('PricingTable.toHtml XSS hardening (new card color props into style=)', () => {
test('a cardBg breakout string cannot escape style=""', () => {
const malicious = '#fff" onmouseover="alert(1)';
const { html } = toHtml({ plans, cardBg: malicious }, '');
expect(html).not.toMatch(/"\s+onmouseover="/);
});
test('a textColor breakout string cannot escape style=""', () => {
const malicious = '#000" onmouseover="alert(1)';
const { html } = toHtml({ plans, textColor: malicious }, '');
expect(html).not.toMatch(/"\s+onmouseover="/);
});
test('a btnBg breakout string cannot escape style=""', () => {
const malicious = '#000" onmouseover="alert(1)';
const { html } = toHtml({ plans, btnBg: malicious }, '');
expect(html).not.toMatch(/"\s+onmouseover="/);
});
test('a btnColor breakout string cannot escape style=""', () => {
const malicious = '#000" onmouseover="alert(1)';
const { html } = toHtml({ plans, btnColor: malicious }, '');
expect(html).not.toMatch(/"\s+onmouseover="/);
});
});
describe('PricingTable.craft.props includes the box-model/animation/visibility rollout props', () => {
test('the new regular-card color props are declared blank by default', () => {
const props = (PricingTable as any).craft.props;
expect(props).toHaveProperty('cardBg', '');
expect(props).toHaveProperty('textColor', '');
expect(props).toHaveProperty('subColor', '');
expect(props).toHaveProperty('featColor', '');
expect(props).toHaveProperty('checkColor', '');
expect(props).toHaveProperty('btnBg', '');
expect(props).toHaveProperty('btnColor', '');
});
test('animation, animationDelay, and all 3 hideOn* flags are declared (blank/false defaults)', () => {
const props = (PricingTable as any).craft.props;
expect(props).toHaveProperty('animation', '');
expect(props).toHaveProperty('animationDelay', '');
expect(props).toHaveProperty('hideOnDesktop', false);
expect(props).toHaveProperty('hideOnTablet', false);
expect(props).toHaveProperty('hideOnMobile', false);
});
});
+75 -17
View File
@@ -1,7 +1,7 @@
import React, { CSSProperties } from 'react';
import { useNode, UserComponent } from '@craftjs/core';
import { cssPropsToString } from '../../utils/style-helpers';
import { escapeHtml, escapeAttr, safeUrl } from '../../utils/escape';
import { escapeHtml, escapeAttr, safeUrl, cssValue } from '../../utils/escape';
interface PricingPlan {
name: string;
@@ -19,6 +19,23 @@ interface PricingTableProps {
featuredBg?: string;
bulletType?: string;
anchorId?: string;
/* ---- Regular (non-featured) card colors ----
All optional; each falls back to the exact literal the card was
previously hard-coded to (or, for the button, to featuredBg -- the
button's original derivation) when left unset, so existing saved
projects render pixel-identical until a color is explicitly picked. */
cardBg?: string;
textColor?: string;
subColor?: string;
featColor?: string;
checkColor?: string;
btnBg?: string;
btnColor?: string;
animation?: string;
animationDelay?: string;
hideOnDesktop?: boolean;
hideOnTablet?: boolean;
hideOnMobile?: boolean;
}
const bulletChars: Record<string, string> = {
@@ -61,6 +78,13 @@ export const PricingTable: UserComponent<PricingTableProps> = ({
featuredBg = '#3b82f6',
bulletType = 'check',
anchorId,
cardBg,
textColor,
subColor,
featColor,
checkColor,
btnBg,
btnColor,
}) => {
const {
connectors: { connect, drag },
@@ -69,6 +93,14 @@ export const PricingTable: UserComponent<PricingTableProps> = ({
selected: node.events.selected,
}));
const regCardBg = cardBg || '#ffffff';
const regTextColor = textColor || '#18181b';
const regSubColor = subColor || '#64748b';
const regFeatColor = featColor || '#4b5563';
const regCheckColor = checkColor || '#10b981';
const regBtnBg = btnBg || featuredBg;
const regBtnColor = btnColor || '#ffffff';
return (
<section
ref={(ref: HTMLElement | null): void => { if (ref) connect(drag(ref)); }}
@@ -95,7 +127,7 @@ export const PricingTable: UserComponent<PricingTableProps> = ({
style={{
flex: '1 1 280px',
maxWidth: '360px',
backgroundColor: plan.isFeatured ? featuredBg : '#ffffff',
backgroundColor: plan.isFeatured ? featuredBg : regCardBg,
border: plan.isFeatured ? 'none' : '1px solid #e2e8f0',
borderRadius: '16px',
padding: '40px 32px',
@@ -127,7 +159,7 @@ export const PricingTable: UserComponent<PricingTableProps> = ({
<h3 style={{
fontSize: '20px',
fontWeight: '600',
color: plan.isFeatured ? '#ffffff' : '#18181b',
color: plan.isFeatured ? '#ffffff' : regTextColor,
marginBottom: '8px',
marginTop: plan.isFeatured ? '8px' : '0',
}}>
@@ -137,14 +169,14 @@ export const PricingTable: UserComponent<PricingTableProps> = ({
<span style={{
fontSize: '48px',
fontWeight: '700',
color: plan.isFeatured ? '#ffffff' : '#18181b',
color: plan.isFeatured ? '#ffffff' : regTextColor,
lineHeight: '1',
}}>
{plan.price}
</span>
<span style={{
fontSize: '16px',
color: plan.isFeatured ? 'rgba(255,255,255,0.8)' : '#64748b',
color: plan.isFeatured ? 'rgba(255,255,255,0.8)' : regSubColor,
}}>
{plan.period}
</span>
@@ -161,12 +193,12 @@ export const PricingTable: UserComponent<PricingTableProps> = ({
{(Array.isArray(plan.features) ? plan.features : []).map((feature, fi) => (
<li key={fi} style={{
fontSize: '14px',
color: plan.isFeatured ? 'rgba(255,255,255,0.9)' : '#4b5563',
color: plan.isFeatured ? 'rgba(255,255,255,0.9)' : regFeatColor,
display: 'flex',
alignItems: 'center',
gap: '8px',
}}>
<span style={{ color: plan.isFeatured ? '#bbf7d0' : '#10b981', fontWeight: '700' }}>{bulletChars[bulletType] || '✓'}</span>
<span style={{ color: plan.isFeatured ? '#bbf7d0' : regCheckColor, fontWeight: '700' }}>{bulletChars[bulletType] || '✓'}</span>
{feature}
</li>
))}
@@ -178,8 +210,8 @@ export const PricingTable: UserComponent<PricingTableProps> = ({
marginTop: 'auto',
display: 'inline-block',
padding: '14px 32px',
backgroundColor: plan.isFeatured ? '#ffffff' : featuredBg,
color: plan.isFeatured ? featuredBg : '#ffffff',
backgroundColor: plan.isFeatured ? '#ffffff' : regBtnBg,
color: plan.isFeatured ? featuredBg : regBtnColor,
textDecoration: 'none',
borderRadius: '8px',
fontWeight: '600',
@@ -207,6 +239,18 @@ PricingTable.craft = {
featuredBg: '#3b82f6',
bulletType: 'check',
anchorId: '',
cardBg: '',
textColor: '',
subColor: '',
featColor: '',
checkColor: '',
btnBg: '',
btnColor: '',
animation: '',
animationDelay: '',
hideOnDesktop: false,
hideOnTablet: false,
hideOnMobile: false,
},
rules: {
canDrag: () => true,
@@ -225,17 +269,31 @@ PricingTable.craft = {
});
const idAttr = props.anchorId ? ` id="${escapeAttr(props.anchorId)}"` : '';
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';
// Sanitized -- regular (non-featured) card color overrides, all raw
// string-interpolation sinks into style="..." below. Each falls back to
// the exact literal the card was previously hard-coded to (or, for the
// button, to featuredBg) when unset, so unmodified pricing tables render
// identically to before these props existed.
const regCardBg = cssValue(props.cardBg) || '#ffffff';
const regTextColor = cssValue(props.textColor) || '#18181b';
const regSubColor = cssValue(props.subColor) || '#64748b';
const regFeatColor = cssValue(props.featColor) || '#4b5563';
const regCheckColor = cssValue(props.checkColor) || '#10b981';
const regBtnBg = cssValue(props.btnBg) || featuredBg;
const regBtnColor = cssValue(props.btnColor) || '#ffffff';
const cards = plans.map((plan) => {
const cardBg = plan.isFeatured ? featuredBg : '#ffffff';
const cardBg = plan.isFeatured ? featuredBg : regCardBg;
const cardBorder = plan.isFeatured ? 'border:none;' : 'border:1px solid #e2e8f0;';
const textColor = plan.isFeatured ? '#ffffff' : '#18181b';
const subColor = plan.isFeatured ? 'rgba(255,255,255,0.8)' : '#64748b';
const featColor = plan.isFeatured ? 'rgba(255,255,255,0.9)' : '#4b5563';
const checkColor = plan.isFeatured ? '#bbf7d0' : '#10b981';
const btnBg = plan.isFeatured ? '#ffffff' : featuredBg;
const btnColor = plan.isFeatured ? featuredBg : '#ffffff';
const textColor = plan.isFeatured ? '#ffffff' : regTextColor;
const subColor = plan.isFeatured ? 'rgba(255,255,255,0.8)' : regSubColor;
const featColor = plan.isFeatured ? 'rgba(255,255,255,0.9)' : regFeatColor;
const checkColor = plan.isFeatured ? '#bbf7d0' : regCheckColor;
const btnBg = plan.isFeatured ? '#ffffff' : regBtnBg;
const btnColor = plan.isFeatured ? featuredBg : regBtnColor;
const scale = plan.isFeatured ? 'transform:scale(1.05);' : '';
const shadow = plan.isFeatured ? 'box-shadow:0 20px 60px rgba(59,130,246,0.3);' : 'box-shadow:0 1px 3px rgba(0,0,0,0.06);';
@@ -80,3 +80,14 @@ describe('Tabs.toHtml deterministic + unique ids (thread node id, resolves id-co
expect(html1).toBe(html2);
});
});
describe('Tabs.craft.props includes the box-model/animation/visibility rollout props', () => {
test('animation, animationDelay, and all 3 hideOn* flags are declared (blank/false defaults)', () => {
const props = (Tabs as any).craft.props;
expect(props).toHaveProperty('animation', '');
expect(props).toHaveProperty('animationDelay', '');
expect(props).toHaveProperty('hideOnDesktop', false);
expect(props).toHaveProperty('hideOnTablet', false);
expect(props).toHaveProperty('hideOnMobile', false);
});
});
+20 -6
View File
@@ -1,7 +1,7 @@
import React, { CSSProperties, useState } from 'react';
import { useNode, UserComponent } from '@craftjs/core';
import { cssPropsToString } from '../../utils/style-helpers';
import { escapeHtml, escapeAttr, scopeId } from '../../utils/escape';
import { escapeHtml, escapeAttr, scopeId, cssValue } from '../../utils/escape';
interface TabItem {
label: string;
@@ -17,6 +17,11 @@ interface TabsProps {
inactiveTabColor?: string;
contentBg?: string;
anchorId?: string;
animation?: string;
animationDelay?: string;
hideOnDesktop?: boolean;
hideOnTablet?: boolean;
hideOnMobile?: boolean;
}
const defaultTabs: TabItem[] = [
@@ -114,6 +119,11 @@ Tabs.craft = {
inactiveTabColor: '#64748b',
contentBg: '#ffffff',
anchorId: '',
animation: '',
animationDelay: '',
hideOnDesktop: false,
hideOnTablet: false,
hideOnMobile: false,
},
rules: {
canDrag: () => true,
@@ -131,11 +141,15 @@ Tabs.craft = {
});
const idAttr = props.anchorId ? ` id="${escapeAttr(props.anchorId)}"` : '';
const tabs = props.tabs || defaultTabs;
const activeTabBg = props.activeTabBg || '#3b82f6';
const activeTabColor = props.activeTabColor || '#ffffff';
const inactiveTabBg = props.inactiveTabBg || '#f1f5f9';
const inactiveTabColor = props.inactiveTabColor || '#64748b';
const contentBg = props.contentBg || '#ffffff';
// Sanitized -- raw string-interpolation sinks below, both into style="..."
// attributes AND into an inline <script> as single-quoted JS string
// literals (a stray `'` there breaks out of the JS string, not just CSS);
// cssValue strips quotes too so it neutralizes both contexts at once.
const activeTabBg = cssValue(props.activeTabBg) || '#3b82f6';
const activeTabColor = cssValue(props.activeTabColor) || '#ffffff';
const inactiveTabBg = cssValue(props.inactiveTabBg) || '#f1f5f9';
const inactiveTabColor = cssValue(props.inactiveTabColor) || '#64748b';
const contentBg = cssValue(props.contentBg) || '#ffffff';
// tabId scopes the functional wiring (onclick/getElementById) as well as
// the ARIA tab<->panel linking ids. It must be BOTH deterministic (so
@@ -46,3 +46,39 @@ describe('Testimonials.toHtml decorative star icons (F2.5)', () => {
stars.forEach((tag: string) => expect(tag).toContain('aria-hidden="true"'));
});
});
describe('Testimonials.toHtml rating aria-label sink (attacker-controlled `rating`, typed number but unchecked)', () => {
test('malicious rating value cannot break out of the star row aria-label attribute', () => {
const malicious = [
{ quote: 'Q', name: 'N', title: 'T', rating: '5"><script>alert(1)</script>' as any },
];
const { html } = toHtml({ testimonials: malicious, layout: 'grid' }, '');
expect(html).not.toContain('<script>alert(1)</script>');
expect(html).not.toContain('5"><script>');
});
test('non-numeric rating falls back to a safe numeric value', () => {
const malicious = [
{ quote: 'Q', name: 'N', title: 'T', rating: 'not-a-number' as any },
];
const { html } = toHtml({ testimonials: malicious, layout: 'grid' }, '');
expect(html).toMatch(/aria-label="Rating: 0 out of 5"/);
});
test('normal numeric rating still renders correctly', () => {
const { html } = toHtml({ testimonials, layout: 'grid' }, '');
expect(html).toContain('aria-label="Rating: 5 out of 5"');
expect(html).toContain('aria-label="Rating: 4 out of 5"');
});
});
describe('Testimonials.craft.props includes the box-model/animation/visibility rollout props', () => {
test('animation, animationDelay, and all 3 hideOn* flags are declared (blank/false defaults)', () => {
const props = (Testimonials as any).craft.props;
expect(props).toHaveProperty('animation', '');
expect(props).toHaveProperty('animationDelay', '');
expect(props).toHaveProperty('hideOnDesktop', false);
expect(props).toHaveProperty('hideOnTablet', false);
expect(props).toHaveProperty('hideOnMobile', false);
});
});
+27 -6
View File
@@ -1,7 +1,7 @@
import React, { CSSProperties } from 'react';
import { useNode, UserComponent } from '@craftjs/core';
import { cssPropsToString } from '../../utils/style-helpers';
import { escapeHtml, escapeAttr } from '../../utils/escape';
import { escapeHtml, escapeAttr, cssValue } from '../../utils/escape';
interface Testimonial {
quote: string;
@@ -18,6 +18,11 @@ interface TestimonialsProps {
cardBg?: string;
starColor?: string;
anchorId?: string;
animation?: string;
animationDelay?: string;
hideOnDesktop?: boolean;
hideOnTablet?: boolean;
hideOnMobile?: boolean;
}
const defaultTestimonials: Testimonial[] = [
@@ -41,10 +46,16 @@ function renderStars(count: number, color: string): React.ReactNode {
}
function starsHtml(count: number, color: string): string {
// `count` is `Testimonial.rating`, typed `number` but arrives unchecked via
// AI update_props / deserialized state -- coerce to a real number before
// it's interpolated into the aria-label attribute below (both the loop
// comparison and the escapeAttr(String(...)) call are safe against any
// non-numeric/garbage value).
const safeCount = Number(count) || 0;
const stars = [1, 2, 3, 4, 5].map((i) =>
`<i class="fa ${i <= 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('');
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> = ({
@@ -125,6 +136,11 @@ Testimonials.craft = {
cardBg: '#f8fafc',
starColor: '#f59e0b',
anchorId: '',
animation: '',
animationDelay: '',
hideOnDesktop: false,
hideOnTablet: false,
hideOnMobile: false,
},
rules: {
canDrag: () => true,
@@ -139,11 +155,16 @@ Testimonials.craft = {
const {
testimonials = defaultTestimonials,
layout = 'grid',
columns = 3,
style = {},
cardBg = '#f8fafc',
starColor = '#f59e0b',
} = 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;
+11 -4
View File
@@ -1,5 +1,5 @@
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';
@@ -68,14 +68,21 @@ export function ctaInlineStyle(cta: CtaButton, defaults: CtaStyleDefaults): CSSP
export function ctaCssString(cta: CtaButton, defaults: CtaStyleDefaults): string {
const variant = cta.variant || 'primary';
// Sanitized -- these are raw string-interpolation sinks into style="...".
// Callers pass user-controlled design-token colors (e.g. HeroSimple's
// buttonBgColor/buttonTextColor/textColor) through CtaStyleDefaults, so
// sanitize once here rather than at every call site.
const outlineText = cssValue(defaults.outlineText) || '#000000';
const primaryBg = cssValue(defaults.primaryBg) || '#000000';
const primaryText = cssValue(defaults.primaryText) || '#ffffff';
switch (variant) {
case 'outline':
return `display:inline-block;padding:14px 36px;background-color:transparent;color:${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':
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':
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`;
}
}
+65
View File
@@ -94,3 +94,68 @@ export const DEVICE_WIDTHS: Record<string, string> = {
tablet: '768px',
mobile: '375px',
};
/* ---------- ENH-Foundation shared presets ----------
Consumed by the reusable StylePanel controls in
src/panels/right/styles/shared.tsx (SizeControl, AspectRatioControl,
BorderControl, etc). Kept in the `{ label, value }` shape used throughout
this file so they drop straight into PresetButtonGrid / ColorSwatchGrid. */
// Width/height preset row used by SizeControl (image + video sizing).
export const SIZE_PRESETS = [
{ label: '25%', value: '25%' },
{ label: '50%', value: '50%' },
{ label: '75%', value: '75%' },
{ label: '100%', value: '100%' },
{ label: 'Auto', value: 'auto' },
{ label: 'Full', value: '100vw' },
];
// CSS `aspect-ratio` values. 'Original' (empty string) clears the property.
export const ASPECT_RATIOS = [
{ label: 'Original', value: '' },
{ label: '1:1', value: '1 / 1' },
{ label: '4:3', value: '4 / 3' },
{ label: '3:2', value: '3 / 2' },
{ label: '16:9', value: '16 / 9' },
{ label: '4:5', value: '4 / 5' },
{ label: '9:16', value: '9 / 16' },
];
// box-shadow presets; 'Colored' is the color-aware option (accent-tinted).
export const SHADOW_PRESETS = [
{ label: 'None', value: 'none' },
{ label: 'S', value: '0 1px 2px rgba(0,0,0,0.08)' },
{ label: 'M', value: '0 4px 8px rgba(0,0,0,0.12)' },
{ label: 'L', value: '0 10px 24px rgba(0,0,0,0.18)' },
{ label: 'Colored', value: '0 8px 20px rgba(59,130,246,0.35)' },
];
export const LINE_HEIGHTS = [
{ label: 'Tight', value: '1.1' },
{ label: 'Snug', value: '1.25' },
{ label: 'Normal', value: '1.5' },
{ label: 'Relaxed', value: '1.75' },
{ label: 'Loose', value: '2' },
];
export const LETTER_SPACINGS = [
{ label: 'Tight', value: '-0.02em' },
{ label: 'Normal', value: 'normal' },
{ label: 'Wide', value: '0.05em' },
{ label: 'Wider', value: '0.1em' },
];
export const OBJECT_FIT = [
{ label: 'Cover', value: 'cover' },
{ label: 'Contain', value: 'contain' },
{ label: 'Fill', value: 'fill' },
{ label: 'None', value: 'none' },
];
export const BORDER_STYLES = [
{ label: 'None', value: 'none' },
{ label: 'Solid', value: 'solid' },
{ label: 'Dashed', value: 'dashed' },
{ label: 'Dotted', value: 'dotted' },
];
+86 -6
View File
@@ -1,13 +1,17 @@
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 { usePages } from '../state/PageContext';
import { DeviceMode } from '../types';
import { DEVICE_WIDTHS } from '../constants/presets';
import { exportBodyHtml } from '../utils/html-export';
import { useIsMobile } from '../hooks/useIsMobile';
import { useMobileChrome } from '../state/MobileChromeContext';
interface CanvasProps {
device: DeviceMode;
/** Item 10: when false, applies `.guides-off` to hide the dashed drop-target guides. */
showGuides: boolean;
}
/**
@@ -81,24 +85,93 @@ const ZonePreview: React.FC<{ craftState: string | null; zone: 'header' | 'foote
<div
ref={containerRef}
data-zone-preview={zone}
className="zone-preview-sep"
style={{
width: '100%',
position: 'relative',
pointerEvents: 'none',
userSelect: 'none',
borderBottom: zone === 'header' ? '1px dashed rgba(245,158,11,0.3)' : 'none',
borderTop: zone === 'footer' ? '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(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,
};
});
const isMobile = useIsMobile();
if (!isEmpty || isDragging) return null;
return (
<div className="empty-canvas-hint">
<i className="fa fa-cubes" aria-hidden />
<span>
{isMobile
? <>Tap <strong>Blocks</strong> below to add content, or pick a Template to start.</>
: 'Drag blocks from the left panel, or pick a Template to start.'}
</span>
</div>
);
};
export const Canvas: React.FC<CanvasProps> = ({ device, showGuides }) => {
const width = DEVICE_WIDTHS[device];
const { isEditingHeader, isEditingFooter, headerPage, footerPage } = usePages();
const isEditingRegularPage = !isEditingHeader && !isEditingFooter;
// Fast-follow item 4: while `MobileSelectionToolbar` is up (fixed, ~53px
// tall, just above the tab bar), it covers whatever content was at the
// very bottom of the canvas's own scroll area -- on a short page, the
// last section could sit permanently under the toolbar with no way to
// scroll past it. Mirrors that toolbar's own visibility condition
// (`selectedId && activeSheet === null`) exactly, computed independently
// here since Canvas has no other reason to depend on the toolbar
// component itself.
const isMobile = useIsMobile();
const { activeSheet } = useMobileChrome();
const { selectedId } = useEditor((state) => {
const selected = state.events.selected;
const id = selected && selected.size > 0 ? (Array.from(selected)[0] as string) : null;
return { selectedId: id && id !== 'ROOT' ? id : null };
});
const mobileToolbarVisible = isMobile && !!selectedId && activeSheet === null;
// Fast-follow item 3: `MobileSelectionToolbar`'s "Style" tap scrolls the
// selected node up towards the top of the canvas so it stays visible
// above the Styles sheet (~65dvh tall) -- but that scroll is still bound
// by the canvas's own natural scroll range. For a selected node near the
// END of the content (very plausibly the last section on the page --
// exactly the kind of node someone just added/duplicated and wants to
// style), there may not be enough scrollable distance below it to bring
// its top all the way up to the visible band above the sheet; the browser
// simply clamps at its existing max scrollTop, leaving the node's top
// stuck behind the sheet with nothing anyone can do about it (verified
// live: the last section of a page landed under the sheet even after the
// scroll-into-view ran). Pad the canvas with a full extra viewport's
// worth of scroll room while the sheet is open with a selection, exactly
// as `has-mobile-selection-toolbar` (item 4) already pads it for the
// fixed toolbar -- generous enough that ANY node, including the very
// last one, can always be scrolled with its top reaching the very top of
// the canvas (comfortably within the "top ~30%" target).
const mobileStylesSheetPad = isMobile && !!selectedId && activeSheet === 'styles';
const frameStyle = isEditingHeader
? { minHeight: '60px', backgroundColor: '#ffffff', padding: '12px 24px', display: 'flex', alignItems: 'center' }
: isEditingFooter
@@ -108,9 +181,13 @@ export const Canvas: React.FC<CanvasProps> = ({ device }) => {
const frameTag = isEditingHeader ? 'header' : isEditingFooter ? 'footer' : 'div';
return (
<div className="editor-canvas">
<div
className="canvas-device-frame"
className={`editor-canvas${mobileToolbarVisible ? ' has-mobile-selection-toolbar' : ''}${
mobileStylesSheetPad ? ' has-mobile-styles-sheet' : ''
}`}
>
<div
className={`canvas-device-frame${showGuides ? '' : ' guides-off'}`}
style={{
width,
maxWidth: '100%',
@@ -140,6 +217,7 @@ export const Canvas: React.FC<CanvasProps> = ({ device }) => {
<ZonePreview craftState={headerPage.craftState} zone="header" />
)}
<div style={{ position: 'relative' }}>
<Frame>
<Element
is={Container}
@@ -148,6 +226,8 @@ export const Canvas: React.FC<CanvasProps> = ({ device }) => {
style={frameStyle}
/>
</Frame>
{isEditingRegularPage && <EmptyCanvasHint />}
</div>
{isEditingRegularPage && (
<ZonePreview craftState={footerPage.craftState} zone="footer" />
+66 -5
View File
@@ -1,16 +1,61 @@
import React, { useState, useCallback } from 'react';
import React, { useState, useCallback, useEffect, useRef } from 'react';
import { useEditor } from '@craftjs/core';
import { TopBar } from '../panels/topbar/TopBar';
import { LeftPanel } from '../panels/left/LeftPanel';
import { RightPanel } from '../panels/right/RightPanel';
import { MobilePanelBar } from '../panels/mobile/MobilePanelBar';
import { MobileSelectionToolbar } from '../panels/mobile/MobileSelectionToolbar';
import { Canvas } from './Canvas';
import { ContextMenu } from '../panels/context-menu/ContextMenu';
import { useContextMenu } from '../hooks/useContextMenu';
import { useKeyboardShortcuts } from '../hooks/useKeyboardShortcuts';
import { useIsMobile } from '../hooks/useIsMobile';
import { MobileChromeProvider } from '../state/MobileChromeContext';
import { LayerFocusProvider } from '../panels/left/LayerFocusContext';
import { DeviceMode } from '../types';
const SHOW_GUIDES_STORAGE_KEY = 'craft-show-guides';
function loadShowGuides(): boolean {
try {
const stored = window.localStorage.getItem(SHOW_GUIDES_STORAGE_KEY);
return stored === null ? true : stored === '1';
} catch {
return true;
}
}
export const EditorShell: React.FC = () => {
const isMobile = useIsMobile();
const [device, setDevice] = useState<DeviceMode>('desktop');
// Phase A: default the canvas to the "mobile" preview width the first
// time we detect a mobile viewport, so the frame fits without the user
// having to reach for the device switcher (now tucked in TopBar's
// overflow menu on mobile). Only fires once, and only if the device is
// still at its initial default -- it must not fight a device the user
// has already picked (e.g. from the overflow menu) on a later re-render.
const mobileDeviceAppliedRef = useRef(false);
useEffect(() => {
if (isMobile && !mobileDeviceAppliedRef.current) {
mobileDeviceAppliedRef.current = true;
setDevice((current) => (current === 'desktop' ? 'mobile' : current));
}
}, [isMobile]);
// Item 10: canvas dashed "guide" outlines toggle -- default ON, persisted
// so the choice survives a reload. Lifted here (rather than owned by
// TopBar or Canvas alone) because the toggle button lives in TopBar but
// the `.guides-off` class it drives is applied to Canvas's
// `.canvas-device-frame`, mirroring how `device` is already lifted for
// the same reason.
const [showGuides, setShowGuidesState] = useState<boolean>(loadShowGuides);
const setShowGuides = useCallback((next: boolean) => {
setShowGuidesState(next);
try {
window.localStorage.setItem(SHOW_GUIDES_STORAGE_KEY, next ? '1' : '0');
} catch {
// Storage unavailable (private browsing, etc.) -- in-memory state still works.
}
}, []);
const { menuState, show: showMenu, hide: hideMenu } = useContextMenu();
const { query } = useEditor();
@@ -33,15 +78,29 @@ export const EditorShell: React.FC = () => {
}, [query, showMenu]);
return (
// Mobile-A2: shared sheet/modal-open state (see MobileChromeContext) --
// provided around the whole shell so TopBar's Templates/Head Code modal
// state and MobilePanelBar's sheet state live in one place. Desktop
// doesn't render MobilePanelBar and TopBar's desktop branch behaves
// identically to before (same booleans, just sourced from context).
<MobileChromeProvider>
<LayerFocusProvider>
<div className="editor-app">
<TopBar device={device} onDeviceChange={setDevice} />
<TopBar
device={device}
onDeviceChange={setDevice}
showGuides={showGuides}
onToggleGuides={() => setShowGuides(!showGuides)}
/>
<div className="editor-container">
<LeftPanel />
{!isMobile && <LeftPanel />}
<div onContextMenu={handleContextMenu} style={{ flex: 1, display: 'flex', minWidth: 0 }}>
<Canvas device={device} />
<Canvas device={device} showGuides={showGuides} />
</div>
<RightPanel />
{!isMobile && <RightPanel />}
</div>
{isMobile && <MobilePanelBar />}
{isMobile && <MobileSelectionToolbar />}
<ContextMenu
visible={menuState.visible}
x={menuState.x}
@@ -50,5 +109,7 @@ export const EditorShell: React.FC = () => {
onClose={hideMenu}
/>
</div>
</LayerFocusProvider>
</MobileChromeProvider>
);
};
+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
)}
</>
);
};
+95
View File
@@ -0,0 +1,95 @@
import { describe, test, expect, afterEach } from 'vitest';
import type { NodeTree } from '@craftjs/core';
import { getClipboardTree, setClipboardTree } from './clipboard';
function makeTree(rootId: string, props: Record<string, unknown> = {}): NodeTree {
return {
rootNodeId: rootId,
nodes: {
[rootId]: {
id: rootId,
data: {
type: { resolvedName: 'Container' },
name: 'Container',
displayName: 'Container',
props,
custom: {},
isCanvas: false,
parent: 'wherever-it-originally-lived',
nodes: [],
linkedNodes: {},
hidden: false,
},
info: {},
events: { selected: false, dragged: false, hovered: false },
dom: null,
related: {},
rules: {},
_hydrationTimestamp: 0,
} as unknown as NodeTree['nodes'][string],
},
};
}
describe('clipboard', () => {
afterEach(() => {
setClipboardTree(null);
});
test('starts empty', () => {
expect(getClipboardTree()).toBeNull();
});
test('set then get returns a tree with the same root id and shape', () => {
const tree = makeTree('node-123', { text: 'hello' });
setClipboardTree(tree);
const got = getClipboardTree();
expect(got).not.toBeNull();
expect(got!.rootNodeId).toBe('node-123');
expect(got!.nodes['node-123'].data.props).toEqual({ text: 'hello' });
});
test('is a shared module-level store -- overwriting replaces the previous value', () => {
setClipboardTree(makeTree('first'));
setClipboardTree(makeTree('second'));
expect(getClipboardTree()!.rootNodeId).toBe('second');
});
test('can be cleared back to null', () => {
setClipboardTree(makeTree('node-123'));
setClipboardTree(null);
expect(getClipboardTree()).toBeNull();
});
test('deep-clones on set: mutating the original tree after set does not affect the stored snapshot', () => {
const original = makeTree('node-123', { text: 'original' });
setClipboardTree(original);
// Mutate the original tree's props object directly (as if the source
// node were edited, or the same live node got copied again).
(original.nodes['node-123'].data.props as Record<string, unknown>).text = 'mutated';
expect(getClipboardTree()!.nodes['node-123'].data.props).toEqual({ text: 'original' });
});
test('deep-clones nested props (arrays/objects), not just the top-level props object', () => {
const original = makeTree('node-123', { links: [{ url: 'https://example.com' }] });
setClipboardTree(original);
(original.nodes['node-123'].data.props as any).links[0].url = 'https://mutated.example.com';
expect((getClipboardTree()!.nodes['node-123'].data.props as any).links[0].url).toBe(
'https://example.com',
);
});
test('survives the original tree object being discarded entirely (detached copy, not a live reference)', () => {
let tree: NodeTree | null = makeTree('node-abc', { text: 'snapshot' });
setClipboardTree(tree);
tree = null; // simulate the original page's node/tree going away entirely
const got = getClipboardTree();
expect(got).not.toBeNull();
expect(got!.nodes['node-abc'].data.props).toEqual({ text: 'snapshot' });
});
});
+81
View File
@@ -0,0 +1,81 @@
import type { Node, NodeId, NodeTree } from '@craftjs/core';
/**
* 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).
*
* Historical bug (cross-page copy/paste): this used to store only the copied
* node's bare id (`clipboardNodeId`) and re-resolve it via `query.node(id)`
* at paste time. That works fine same-page, but the moment the user switches
* pages the canvas is re-deserialized to the target page's Craft.js state --
* the copied id no longer exists in `query` at all -- so a cross-page paste
* silently no-op'd (or threw, caught, and swallowed). Storing a detached
* TREE SNAPSHOT at copy time instead means paste never needs to look the
* source id up again: it just hands the snapshot to `regenerateTreeIds` +
* `actions.addNodeTree`, which works identically regardless of which page's
* state is currently loaded on the canvas.
*/
let clipboardTree: NodeTree | null = null;
/**
* Deep, detached clone of a live Craft.js `NodeTree` (as returned by
* `query.node(id).toNodeTree()`).
*
* Not a plain `structuredClone(tree)`: for a REAL (live) Craft.js node,
* `data.type` is the actual component function/class reference (not a
* serializable `{resolvedName}` wrapper) -- `structuredClone` cannot clone a
* function and throws `DataCloneError` (see the identical note on
* `regenerateTreeIds` in `utils/craft-tree.ts`, which hit this exact bug
* historically). `type` is a stable reference shared by every node of that
* component across the whole app (it doesn't change per page), so it's safe
* to keep by reference -- only the mutable per-node data (`props`, `custom`,
* `nodes`, `linkedNodes`) needs an actual deep copy so a later mutation (a
* subsequent paste's `setProp`, or a fresh copy of the same live node)
* can never reach back into this stored snapshot.
*/
function cloneNodeTree(tree: NodeTree): NodeTree {
const nodes: Record<NodeId, Node> = {};
for (const [id, node] of Object.entries(tree.nodes)) {
nodes[id] = {
...node,
data: {
...node.data,
props: structuredClone(node.data.props),
custom: structuredClone(node.data.custom),
nodes: [...(node.data.nodes || [])],
linkedNodes: { ...(node.data.linkedNodes || {}) },
},
};
}
return { rootNodeId: tree.rootNodeId, nodes };
}
/**
* Returns the tree snapshot currently on the clipboard, or null if empty.
* The returned tree is safe to hand straight to `regenerateTreeIds` --
* `regenerateTreeIds` never mutates its input, so repeated pastes of the
* same clipboard contents (including across a page switch) all work off the
* same untouched snapshot.
*/
export function getClipboardTree(): NodeTree | null {
return clipboardTree;
}
/**
* Sets (or clears, with `null`) the tree snapshot on the clipboard. The tree
* is deep-cloned before being stored (see `cloneNodeTree`) so it is fully
* detached from the live Craft.js node it was captured from -- it survives
* that node being deleted, mutated, or (the whole point) the canvas being
* re-deserialized to a different page entirely.
*/
export function setClipboardTree(tree: NodeTree | null): void {
clipboardTree = tree ? cloneNodeTree(tree) : null;
}
+153
View File
@@ -0,0 +1,153 @@
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 { useIsMobile } from './useIsMobile';
/**
* Mobile-A2 (review item 5): useIsMobile is the single source of truth every
* mobile-only branch (bottom tab bar, collapsed topbar, bottom sheets) reads
* from, so it needs direct coverage of: (a) it reflects matchMedia's
* `matches`, (b) the `change` listener is registered AND cleaned up on
* unmount (a leaked listener would keep re-rendering an unmounted tree /
* leak the component instance), and (c) the legacy Safari<14
* addListener/removeListener fallback is used when addEventListener isn't
* available.
*
* DOM-harness pattern mirrors PageContext.pure-updaters.test.tsx /
* useKeyboardShortcuts.test.tsx: bare createRoot + act, no @testing-library
* (not a project dependency).
*/
let container: HTMLDivElement;
let root: Root;
let originalMatchMedia: typeof window.matchMedia;
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();
}
/** A minimal fake MediaQueryList. `mode` picks which listener API it
* exposes, so tests can force the legacy fallback path. */
function makeMql(matches: boolean, mode: 'modern' | 'legacy' = 'modern') {
const listeners: Array<() => void> = [];
const mql: any = {
matches,
media: '(max-width: 768px)',
};
if (mode === 'modern') {
mql.addEventListener = vi.fn((_type: string, fn: () => void) => listeners.push(fn));
mql.removeEventListener = vi.fn((_type: string, fn: () => void) => {
const i = listeners.indexOf(fn);
if (i !== -1) listeners.splice(i, 1);
});
} else {
mql.addListener = vi.fn((fn: () => void) => listeners.push(fn));
mql.removeListener = vi.fn((fn: () => void) => {
const i = listeners.indexOf(fn);
if (i !== -1) listeners.splice(i, 1);
});
}
return { mql, fire: () => listeners.forEach((fn) => fn()), listenerCount: () => listeners.length };
}
let probedValue: boolean | null = null;
const Probe: React.FC = () => {
probedValue = useIsMobile();
return <span data-value={String(probedValue)} />;
};
beforeEach(() => {
originalMatchMedia = window.matchMedia;
probedValue = null;
});
afterEach(() => {
window.matchMedia = originalMatchMedia;
});
describe('useIsMobile', () => {
test('returns true when the mobile media query matches', () => {
const { mql } = makeMql(true);
window.matchMedia = vi.fn(() => mql) as any;
render(<Probe />);
expect(probedValue).toBe(true);
unmount();
});
test('returns false when the mobile media query does not match', () => {
const { mql } = makeMql(false);
window.matchMedia = vi.fn(() => mql) as any;
render(<Probe />);
expect(probedValue).toBe(false);
unmount();
});
test('registers the change listener via addEventListener and updates on change', () => {
const { mql, fire, listenerCount } = makeMql(false);
window.matchMedia = vi.fn(() => mql) as any;
render(<Probe />);
expect(probedValue).toBe(false);
expect(mql.addEventListener).toHaveBeenCalledWith('change', expect.any(Function));
expect(listenerCount()).toBe(1);
// Flip the query result and fire the registered 'change' listener --
// the hook must re-read mql.matches, not just toggle blindly.
mql.matches = true;
act(() => {
fire();
});
expect(probedValue).toBe(true);
unmount();
});
test('cleans up the addEventListener listener on unmount', () => {
const { mql, listenerCount } = makeMql(true);
window.matchMedia = vi.fn(() => mql) as any;
render(<Probe />);
expect(listenerCount()).toBe(1);
unmount();
expect(mql.removeEventListener).toHaveBeenCalledWith('change', expect.any(Function));
expect(listenerCount()).toBe(0);
});
test('falls back to legacy addListener/removeListener when addEventListener is unavailable', () => {
const { mql, fire, listenerCount } = makeMql(false, 'legacy');
window.matchMedia = vi.fn(() => mql) as any;
render(<Probe />);
expect(probedValue).toBe(false);
expect(mql.addListener).toHaveBeenCalledWith(expect.any(Function));
expect(listenerCount()).toBe(1);
mql.matches = true;
act(() => {
fire();
});
expect(probedValue).toBe(true);
unmount();
expect(mql.removeListener).toHaveBeenCalledWith(expect.any(Function));
expect(listenerCount()).toBe(0);
});
});
+72
View File
@@ -0,0 +1,72 @@
import { useEffect, useState } from 'react';
/** Mobile breakpoint for the editor chrome (Phase A). Keep in sync with the
* `@media (max-width: 768px)` block in `src/styles/editor.css` -- both the
* CSS and this hook must agree on where mobile chrome kicks in. */
export const MOBILE_BREAKPOINT_PX = 768;
const MOBILE_QUERY = `(max-width: ${MOBILE_BREAKPOINT_PX}px)`;
function computeIsMobile(): boolean {
if (typeof window === 'undefined') return false;
if (typeof window.matchMedia === 'function') {
try {
return window.matchMedia(MOBILE_QUERY).matches;
} catch {
// Fall through to the width check below (e.g. some older/embedded
// WebViews expose a matchMedia that throws instead of omitting it).
}
}
return window.innerWidth <= MOBILE_BREAKPOINT_PX;
}
/**
* Reports whether the viewport is at or below the mobile editor breakpoint.
* Drives every mobile-only branch introduced in Phase A (bottom tab bar,
* collapsed topbar, bottom sheets) -- desktop rendering must stay identical
* above the breakpoint, so this is the single source of truth both React
* and (via the matching CSS media query) plain CSS use to agree on "mobile".
*
* Falls back to a `resize` listener + `window.innerWidth` when
* `matchMedia` isn't available (e.g. some test/JSDOM environments), so the
* hook degrades gracefully rather than throwing.
*/
export function useIsMobile(): boolean {
const [isMobile, setIsMobile] = useState<boolean>(computeIsMobile);
useEffect(() => {
if (typeof window === 'undefined') return;
if (typeof window.matchMedia === 'function') {
let mql: MediaQueryList | null = null;
try {
mql = window.matchMedia(MOBILE_QUERY);
} catch {
mql = null;
}
if (mql) {
const handleChange = () => setIsMobile(mql!.matches);
handleChange();
if (typeof mql.addEventListener === 'function') {
mql.addEventListener('change', handleChange);
return () => mql!.removeEventListener('change', handleChange);
}
// Safari < 14 only supports the deprecated addListener/removeListener pair.
const legacyMql = mql as MediaQueryList & {
addListener?: (listener: () => void) => void;
removeListener?: (listener: () => void) => void;
};
legacyMql.addListener?.(handleChange);
return () => legacyMql.removeListener?.(handleChange);
}
}
const handleResize = () => setIsMobile(window.innerWidth <= MOBILE_BREAKPOINT_PX);
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, []);
return isMobile;
}
@@ -0,0 +1,295 @@
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 { getClipboardTree, setClipboardTree } 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.
*
* Also covers the cross-page clipboard fix: copy stores a detached TREE
* SNAPSHOT (`setClipboardTree`), not a bare node id -- so paste never needs
* to re-resolve the original node via `query.node(id)`, which is exactly
* what breaks once the canvas has been re-deserialized to a different page
* (see `hooks/clipboard.ts` and the cross-page integration test in
* `test-utils/integration/duplicate-paste.integration.test.tsx`).
*
* 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();
const selectNodeMock = 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; nodes?: string[] } }> = {
'selected-1': { data: { parent: 'parent-container-1' } },
'parent-container-1': { data: { parent: null, nodes: ['other-sibling', 'selected-1'] } },
ROOT: { data: { parent: null, nodes: [] } },
'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,
selectNode: selectNodeMock,
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();
selectNodeMock.mockClear();
regenerateTreeIdsMock.mockClear();
setClipboardTree(null);
});
afterEach(() => {
setClipboardTree(null);
if (root) unmount();
});
describe('useKeyboardShortcuts: Ctrl/Cmd+C / Ctrl/Cmd+V', () => {
test('Ctrl+C copies the selected node`s subtree (a tree snapshot, not a bare id) to the clipboard', () => {
render(<Consumer />);
selectedIds = ['copied-root-1'];
pressKey('c');
const clip = getClipboardTree();
expect(clip).not.toBeNull();
expect(clip!.rootNodeId).toBe('copied-root-1');
expect(Object.keys(clip!.nodes).sort()).toEqual(['copied-child-1', 'copied-root-1']);
});
test('Ctrl+V pastes as a sibling of the selection, immediately after it, with FRESH ids', () => {
render(<Consumer />);
selectedIds = ['copied-root-1'];
pressKey('c');
expect(getClipboardTree()!.rootNodeId).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(getClipboardTree());
expect(addNodeTreeMock).toHaveBeenCalledTimes(1);
const [pastedTree, targetParent, insertIndex] = addNodeTreeMock.mock.calls[0];
// Sibling of the current selection: selected-1's data.parent.
expect(targetParent).toBe('parent-container-1');
// Immediately after selected-1 (index 1 among parent-container-1's
// children), matching duplicate()'s "insert right after" UX.
expect(insertIndex).toBe(2);
// 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);
}
// The new copy is selected, same as duplicate()'s existing UX.
expect(selectNodeMock).toHaveBeenCalledWith(pastedTree.rootNodeId);
});
test('Ctrl+V with selection at ROOT falls back to appending into ROOT', () => {
render(<Consumer />);
selectedIds = ['copied-root-1'];
pressKey('c');
selectedIds = ['ROOT'];
pressKey('v');
expect(addNodeTreeMock).toHaveBeenCalledTimes(1);
const [, targetParent, insertIndex] = addNodeTreeMock.mock.calls[0];
expect(targetParent).toBe('ROOT');
expect(insertIndex).toBeUndefined();
});
test('Ctrl+V with nothing selected falls back to appending into ROOT', () => {
render(<Consumer />);
selectedIds = ['copied-root-1'];
pressKey('c');
selectedIds = [];
pressKey('v');
expect(addNodeTreeMock).toHaveBeenCalledTimes(1);
const [, targetParent, insertIndex] = addNodeTreeMock.mock.calls[0];
expect(targetParent).toBe('ROOT');
expect(insertIndex).toBeUndefined();
});
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(getClipboardTree()).toBeNull();
setClipboardTree(COPIED_TREE);
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(getClipboardTree()).toBeNull();
activeElementSpy.mockRestore();
});
});
+62
View File
@@ -2,6 +2,7 @@ import { useEffect } from 'react';
import { useEditor } from '@craftjs/core';
import { findDeletableTarget } from '../utils/craft-helpers';
import { regenerateTreeIds } from '../utils/craft-tree';
import { getClipboardTree, setClipboardTree } from './clipboard';
function isInputFocused(): boolean {
const el = document.activeElement;
@@ -85,6 +86,67 @@ export function useKeyboardShortcuts() {
return;
}
// Ctrl+C: copy the selected node's subtree (a detached snapshot, not
// just its id -- see clipboard.ts for why: an id-based clipboard can't
// survive a page switch, since the copied id no longer exists in
// `query` once the canvas is re-deserialized to a different page).
if (ctrl && (e.key === 'c' || e.key === 'C')) {
e.preventDefault();
try {
const selected = query.getEvent('selected').all();
if (selected.length > 0 && selected[0] !== 'ROOT') {
setClipboardTree(query.node(selected[0]).toNodeTree());
}
} catch (err) {
console.error('Copy failed:', err);
}
return;
}
// Ctrl+V: paste the clipboard tree as a sibling of the current
// selection (immediately after it, matching duplicate()'s UX), or
// append to ROOT when nothing is selected. Works regardless of which
// page is currently on the canvas -- the clipboard tree is a detached
// snapshot, not a reference to a node that may no longer exist here.
if (ctrl && (e.key === 'v' || e.key === 'V')) {
e.preventDefault();
try {
const clip = getClipboardTree();
if (!clip) return;
const selected = query.getEvent('selected').all();
const selectedId = selected.length > 0 ? selected[0] : null;
let targetParentId = 'ROOT';
let insertIndex: number | undefined;
if (selectedId && selectedId !== 'ROOT') {
const node = query.node(selectedId).get();
const parentId: string | null | undefined = node?.data?.parent;
if (parentId) {
targetParentId = parentId;
try {
const siblings: string[] = query.node(parentId).get()?.data?.nodes || [];
const idx = siblings.indexOf(selectedId);
if (idx !== -1) insertIndex = idx + 1;
} catch {
// Leave insertIndex undefined -- addNodeTree appends when omitted.
}
}
}
const tree = regenerateTreeIds(clip);
if (insertIndex !== undefined) {
actions.addNodeTree(tree, targetParentId, insertIndex);
} else {
actions.addNodeTree(tree, targetParentId);
}
actions.selectNode(tree.rootNodeId);
} catch (err) {
console.error('Paste failed:', err);
}
return;
}
// Escape: deselect all
if (e.key === 'Escape') {
e.preventDefault();

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