Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
22 KiB
Site Builder Hardening + Asset Picker Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Fix the full 2026-07-12 Fable audit — Critical stored-XSS + High data-loss bugs, the Medium/Low correctness/dead-code/a11y backlog — and ship the unified image/asset picker, across the Craft.js site builder.
Architecture: Work proceeds in dependency-ordered phases. Phase A builds a shared escaping/URL util that many later tasks consume, so it lands first. Phases that edit overlapping component files are serialized; genuinely disjoint tasks within a phase may run in parallel. Every task is TDD where a behavior is testable, and ends in a commit.
Tech Stack: Vite 6 + React 18 + TypeScript 5 (strict) + @craftjs/core 0.2.x + Vitest. DOMPurify already a dependency.
Global Constraints
- Active source is
craft/only. Never touch the legacy top-level GrapesJS builder. - Verify with
cd craft && npx vitest runandcd craft && npm run build(runstsc— strict). Both must be green before a task is "done". - No native
alert/confirm/promptin any UI intended for the WHP panel (WHP UI ban). Use existing WHP/in-app patterns; do NOT addwindow.confirm. toHtmloutput must stay deterministic (noMath.random()/Date.now()in export paths) so published HTML diffs/caches cleanly.- Inline styles only for user content; editor chrome uses
editor.css. Follow existing component pattern (render +.craft+ statictoHtml). - Frequent commits: one per task minimum. Conventional-commit messages. End commit messages with the Co-Authored-By trailer used in this repo.
- Path alias
@/→craft/src/.
Phase A — Escaping/URL foundation (Critical XSS). Blocks C, and precedes D–G edits on shared files.
Task A1: Shared escape.ts util
Files:
- Create:
craft/src/utils/escape.ts - Test:
craft/src/utils/escape.test.ts - Modify:
craft/src/utils/html-export.ts(delete localescapeHtmlat ~:201, import fromescape.ts)
Produces (consumed by nearly every later task):
export function escapeHtml(s: string): string; // & < > " — text/content + double-quoted attrs
export function escapeAttr(s: string): string; // escapeHtml + ' + ` — any attribute context
export function safeUrl(s: string): string; // '' for javascript:/vbscript:/data:text/html schemes (case/space/entity-insensitive), else trimmed input
- Step 1: Write failing tests covering:
escapeHtml('a & b <c> "d"')→a & b <c> "d";escapeAttr("' \")escapes single-quote and backtick;safeUrl('javascript:alert(1)')→'';safeUrl(' JavaScript:alert(1)')→'';safeUrl('data:text/html;base64,x')→'';safeUrl('https://x.com/a?b=1&c=2')unchanged;safeUrl('/relative')unchanged;safeUrl('mailto:x@y.com')unchanged;safeUrl('#anchor')` unchanged; non-string / null-ish inputs coerce safely. - Step 2: Run
npx vitest run src/utils/escape.test.ts→ FAIL. - Step 3: Implement
escape.ts.safeUrlnormalizes by lowercasing, stripping whitespace and HTML entities/control chars, then rejecting a^(javascript|vbscript|data:text/html)scheme; allow all other/relative URLs. - Step 4: Update
html-export.tsto importescapeHtmlfromescape.ts; delete its private copy. - Step 5: Run
npx vitest run src/utils/escape.test.ts+npm run build→ PASS. - Step 6: Commit
feat(builder): add shared escape/safeUrl util.
Task A2: Replace the 27 local esc/escapeHtml copies
Files (modify — import escapeHtml/escapeAttr from @/utils/escape, delete the local def): every file matching grep -rlE "const (esc|escapeHtml)\s*=|function (esc|escapeHtml)" craft/src. Includes (non-exhaustive) components/basic/{ButtonLink,Icon,SocialLinks,Logo,Menu,Navbar,Footer,StarRating}.tsx, components/sections/{HeroSimple,CTASection,CallToAction,FeaturesGrid,PricingTable,Gallery,ContentSlider,Testimonials,Countdown,Tabs,Accordion,SubscribeForm,MapEmbed,...}.tsx, components/layout/{Section,BackgroundSection,Container,ColumnLayout}.tsx, components/forms/*.tsx, components/basic/_cta-helpers.tsx.
Interfaces — Consumes: A1's escapeHtml/escapeAttr.
- Step 1: For each file, replace the local escaper with the import. Use
escapeAttrfor values emitted inside attributes (title=,alt=, single/double-quoted attrs);escapeHtmlfor text-node content. This fixes the "omits&" and "omits quotes" variants at once. - Step 2:
grep -rE "const (esc|escapeHtml)\s*=|function (esc|escapeHtml)" craft/src→ onlyescape.tsremains (0 others). - Step 3:
npm run build→ PASS (tsc catches missed imports). - Step 4: Run existing toHtml tests
npx vitest run→ PASS. - Step 5: Commit
refactor(builder): use shared escaper everywhere, drop 27 copies.
(May be split into 2-3 commits by directory for review sanity; behavior-preserving.)
Task A3: safeUrl on all exported URLs
Files (modify toHtml href/src/action/url(...) emission): basic/ButtonLink.tsx:230, basic/Icon.tsx:303,320, basic/SocialLinks.tsx:440, basic/Logo.tsx:417, basic/Menu.tsx:497, basic/Navbar.tsx:856-918, basic/_cta-helpers.tsx:86, sections/ContentSlider.tsx:484, sections/FeaturesGrid.tsx:292, sections/PricingTable.tsx:447, media/ImageBlock.tsx:479, media/VideoBlock.tsx:725,792, sections/Gallery.tsx img src, background-image emitters layout/BackgroundSection.tsx:191, sections/HeroSimple.tsx:421,441, sections/CallToAction.tsx:379, layout/Section.tsx bg.
Consumes: A1 safeUrl, escapeAttr.
- Step 1: Wrap each exported URL:
href="${escapeAttr(safeUrl(props.href||''))}"; for CSSurl(...)useurl('${escapeAttr(safeUrl(x))}'). - Step 2: Write regression tests (in each component's
.toHtml.test.ts, or a newtoHtml-escaping.test.ts): ajavascript:href renders empty; a"-containing href stays inside its attribute; bgurl()with")cannot break out. - Step 3:
npx vitest run+npm run build→ PASS. - Step 4: Commit
fix(builder): neutralize javascript:/breakout URLs in HTML export.
Task A4: JS-string / raw-HTML context fixes
Files: basic/HtmlBlock.tsx:144, sections/Countdown.tsx:298, sections/Gallery.tsx:296.
- Step 1: Tests — HtmlBlock
toHtml({code:'<script>x</script>'})output is DOMPurify-sanitized (no raw<script>); CountdowntoHtmlwithtargetDate:'2026-01-01");alert(1)//'produces no injected JS (date rejected/blanked or JSON-encoded); Gallery lightbox usesdata-src(noonclick="..._open('...')"interpolation). - Step 2: Run → FAIL.
- Step 3: Implement: HtmlBlock
return { html: purifyHtml(props.code || '') }. Countdown: validate/^\d{4}-\d{2}-\d{2}([T ].*)?$/; emitvar target=new Date(${JSON.stringify(validDate)}).getTime(). Gallery: emitdata-src="${escapeAttr(img.src)}"+class="..._thumb", attach one delegated click listener in the gallery's inline script that readsgetAttribute('data-src'). - Step 4: Run → PASS;
npm run build. - Step 5: Commit
fix(builder): sanitize HtmlBlock export, JS-context in Countdown/Gallery.
Task A5: Escape values flowing into cssPropsToString
Files: craft/src/utils/style-helpers.ts (cssPropsToString).
- Step 1: Test a style value containing
";color:red;background:url(x)"cannot terminate the declaration / inject extra props;url()contents run throughsafeUrl. - Step 2: Implement value sanitization (strip/encode
",;where they'd break out;safeUrlinsideurl(...)). Keep valid multi-value styles intact. - Step 3:
npx vitest run(all toHtml tests still pass) +npm run build. - Step 4: Commit
fix(builder): sanitize style-string emission.
Phase B — Data-loss / state corruption (High). Independent of A; disjoint files, may run parallel to A.
Task B1: Node-id regeneration for copy/paste/duplicate
Files:
- Create:
craft/src/utils/craft-tree.ts—regenerateTreeIds(query, tree): NodeTree - Modify:
panels/context-menu/ContextMenu.tsx(duplicate,pasteNode),hooks/useKeyboardShortcuts.ts(duplicate) - Test:
craft/src/utils/craft-tree.test.ts
Produces: regenerateTreeIds(query, tree) → a NodeTree with every rootNodeId, nodes key, .id, .data.parent, and linkedNodes value remapped to fresh ids (via Craft's id generator), preserving structure.
- Step 1: Test — given a serialized 3-node subtree,
regenerateTreeIdsreturns a tree whose id set is disjoint from the input's and internally consistent (parents/linkedNodes point to new ids). - Step 2: Run → FAIL.
- Step 3: Implement helper. Update
duplicate/pasteNode/keyboard-duplicate toaddNodeTree(regenerateTreeIds(query, tree), parentId). FixpasteNodeparent: insert as sibling of the target (target'sdata.parent), fallbackROOT. Remove the deadclonedTreecomputation induplicate. - Step 4: Run +
npm run build→ PASS. - Step 5: Commit
fix(builder): regenerate node ids on duplicate/paste to prevent state corruption.
Task B2: Header/Footer save routing
Files: hooks/useWhpApi.ts (save), state/PageContext.tsx (confirm the commit-current-state fn name + isEditingHeader/Footer flags), test craft/src/hooks/useWhpApi.save.test.ts.
- Step 1: Test — with
activePageId==='__header__'and a live canvas,save()'s payload puts the live serialization inheader_craft_state/header_htmland does NOT overwrite a normal page's slot nor the top-levelcraft_statewith header content. (Mockfetch,usePages,query.serialize.) - Step 2: Run → FAIL.
- Step 3: Implement: before serializing, commit the live canvas to the active zone (call PageContext's existing save-current-state). Branch on editing-header/footer: assign live serialization to header/footer slots; otherwise current behavior. Ensure header/footer HTML is exported from the freshly-committed state, not stale
headerPage.craftState. - Step 4: Run +
npm run build→ PASS. - Step 5: Commit
fix(builder): route live header/footer edits correctly on save.
Task B3: AI-boundary validation
Files: craft/src/utils/apply-ai-response.ts, test apply-ai-response.test.ts (extend).
- Step 1: Tests — a node with unknown
resolvedNameis skipped (no throw);update_propscannot overwritenode_id/style; AInode_id==='ROOT'or duplicate is rejected/regenerated. - Step 2: Run → FAIL.
- Step 3: Implement: validate
resolvedNameagainstcomponentResolverkeys; allowlistupdate_propskeys per node (excludenode_id; mergestyleshallowly, never replace wholesale via blind assign); dedupe/validate ids; fail soft +console.warn. ReusePageContext.treeToStateguard logic. - Step 4: Run +
npm run build→ PASS. - Step 5: Commit
fix(builder): validate AI response before deserializing into craft state.
Phase C — Unified asset picker. AFTER Phase A (shares component files: ImageBlock/Logo/Navbar/Gallery/Features/etc.).
Task C1: utils/assets.ts
Files: Create craft/src/utils/assets.ts, test assets.test.ts. Modify panels/right/styles/shared.tsx to re-export uploadToWhp as a thin wrapper over uploadAsset.
Produces:
export interface Asset { name: string; url: string; type: string; }
export async function uploadAsset(file: File): Promise<string | null>; // POST upload_asset; blob: fallback if no WHP_CONFIG
export async function listAssets(mediaType?: 'image'|'video'|'any'): Promise<Asset[]>; // [] if no WHP_CONFIG; filter by type prefix
- Step 1: Tests —
uploadAssetw/oWHP_CONFIGreturns ablob:URL; with config, POSTs toupload_assetand returnsdata.url;listAssets('image')returns onlytypestartsWithimage;listAssets()w/o config returns[]. (Mockfetch,window.WHP_CONFIG,URL.createObjectURL.) - Step 2: Run → FAIL.
- Step 3: Implement (bodies lifted from existing
shared.tsxuploadToWhp+ thelist_assetsfetch pattern). Updateshared.tsxuploadToWhp→export const uploadToWhp = uploadAsset. - Step 4: Run +
npm run build→ PASS. - Step 5: Commit
feat(builder): shared asset upload/list util.
Task C2: ui/AssetPicker.tsx
Files: Create craft/src/ui/AssetPicker.tsx, test AssetPicker.test.tsx.
Consumes: C1 uploadAsset/listAssets.
Produces:
interface AssetPickerProps {
value: string;
onChange: (url: string) => void;
mediaType?: 'image'|'video'|'any'; // default 'image'
variant?: 'full'|'compact'; // default 'full'
placeholder?: string;
}
export const AssetPicker: React.FC<AssetPickerProps>;
- Step 1: Tests — renders
value; grid select callsonChange(asset.url); URL "Apply" callsonChange(input);listAssetscalled withmediaType; Browse hidden when noWHP_CONFIG;variant="compact"renders the compact row. - Step 2: Run → FAIL.
- Step 3: Implement both variants sharing all logic (upload/browse/url state);
fullmirrors currentImageStylePanellayout,compactthe tight row. File-inputacceptand grid filter frommediaType. - Step 4: Run +
npm run build→ PASS. - Step 5: Commit
feat(builder): reusable AssetPicker (full/compact, media-typed).
Task C3: Wire AssetPicker into all image fields
Files (replace ad-hoc image input with <AssetPicker>):
-
Full:
panels/right/styles/ImageStylePanel.tsx,MediaStylePanel.tsx,BackgroundSectionStylePanel.tsx,HeroStylePanel.tsx(image),components/layout/Container.tsx(bg),components/basic/Logo.tsx,components/basic/Navbar.tsx(logo),components/media/ImageBlock.tsx. -
Compact:
panels/right/styles/FeaturesEditor.tsx,components/sections/FeaturesGrid.tsx,components/sections/Gallery.tsx,components/sections/ContentSlider.tsx. -
Step 1: For each, render
<AssetPicker value={x} onChange={v=>setProp(...x=v)} variant={full|compact}/>, writing to that consumer's existing prop. Remove now-dead localshowBrowser/browserAssets/handleBrowse/handleUploadstate. -
Step 2:
npm run build→ PASS;npx vitest run→ PASS (toHtml unchanged). -
Step 3: Manual smoke via
npm run dev: each field shows Upload+Browse+URL and selecting an asset updates the canvas. -
Step 4: Commit (split by group)
feat(builder): unify image fields on AssetPicker.
Task C4: Remove duplicated upload/browse code
- Step 1: Delete leftover local
uploadToWhpcopies (FeaturesEditor etc.) + HeroStylePanelAssetBrowsernow replaced.grepconfirms no orphaned copies. - Step 2:
npm run build+npx vitest run→ PASS. - Step 3: Commit
refactor(builder): drop duplicated asset browse/upload code.
Phase D — Correctness / parity (Medium). After A (shared files).
- D1 — Emit collected props. Files:
forms/ContactForm.tsx(render+exportsuccessMessage),layout/Container.tsx(applycssId/cssClassin render +toHtml),sections/FeaturesGrid.tsx(makemediaTypetoggle actually select icon-vs-image instead offeat.imagetruthiness). Test each renders/exports. Commitfix(builder): emit ContactForm/Container/FeaturesGrid props. - D2 — Export parity.
sections/Testimonials.tsxsingle-layout export matches editor (carousel markup or documented static + aria);layout/ColumnLayout.tsxwidth slider re-derives child widths intoHtml. Tests. Commit. - D3 — Slug dedupe.
state/PageContext.tsx(addPage/renamePage/replaceAllPages) +PagesPanel.tsx:uniqueSlug(base, existing)appends-2,-3. Test collision. Commit. - D4 — VideoBlock URL parsing.
media/VideoBlock.tsx:32-38: handleyoutu.be,/shorts/ID,/live/ID,watch?...&v=ID, Vimeovimeo.com/ID/HASH. Tests. Commit. - D5 — TemplateModal race.
panels/topbar/TemplateModal.tsx: await the load sequence (promisified) thenonClose(); show Loading state. Commit. - D6 — Pure state updaters.
state/PageContext.tsx:248-262,318-323: moveloadState/setActivePageId/ref mutations out ofsetPages/setHeaderPageupdater callbacks into effects/post-set logic. Verify no double-deserialize under StrictMode. Commit. - D7 — Footer edit guard.
basic/Footer.tsx: adopt theeditedTextRef+commit-on-deselect pattern fromHeading.tsx. Commit. - D8 — Preview head + $-safe replace.
panels/topbar/TopBar.tsx:189-198: passheadCodetoexportToHtml; replacehtml.replace(bodyMatch[1], composedBody)with a function replacer to avoid$-sequence corruption. Commit. - D9 — headCode consolidation. Remove dead per-page
headCode(types/index.ts:19+PageContexthardcoded''); keep onlySiteDesign.headCode. Commit.
Each D task: write/adjust a test, implement, npx vitest run + npm run build green, commit.
Phase E — Dead code / maintainability (Medium). After C (so picker isn't removed as "dead").
- E1 — Remove dead settings UI. Confirm via
grepthatRightPanel.tsxrenders only<GuidedStyles/>and that each*Settingscomponent + all ofui/(AdvancedTab/TypographyControl/BorderControl/SpacingInput/AnchorIdField/SettingsTabs) is imported ONLY byrelated.settings. Delete the deadrelated.settingspanels and theui/files that become orphaned. Keepui/files still used by live StylePanels (verify each individually before deleting).npm run buildproves no live import breaks. Commitchore(builder): remove dead related.settings panels + orphaned ui/. - E2 — HeaderZone/FooterZone. They're absent from
resolver.tsand dead → delete the files, and add a guard inapply-ai-response/treeToStatethat skips unknownresolvedName(already added in B3 — just confirm). UpdateCLAUDE.mdstale component list/count. Commit. - E3 — Consolidate tree-flatteners. Extract one shared flattener used by
PageContext.treeToStateandapply-ai-response.buildNodeTree; delete deadserializeTreeForCraft. Ensure thestyle:[]→{}normalization is in the shared path. Tests for both callers. Commit. - E4 — Panel/modal dedup. Extract
useNodeProp(id)hook intoshared.tsx(replacesetProp/setPropStyleboilerplate in the 9 style panels); extract the shared array-item field editor used bySectionTypePanel+GenericPropsEditor; extract one<Modal>forTemplateModal/HeadCodeModal/SitesmithModal; extract the shared nav-link editor (Menu↔Navbar). Behavior-preserving;npx vitest rungreen. Commit per extraction. - E5 — Typing. Type
StylePanelProps.nodePropsper-panel where feasible; addPatchOp/SerializedTreeNodetypes at the AI boundary. Reduceanyinapply-ai-response.ts/treeToState.npm run buildstrict green. Commit.
Phase F — Accessibility (Medium). After C/E (touches widgets + panels).
- F1 — Exported widget a11y.
ContentSlider(prev/next/dotsaria-label,aria-live, pause control),Tabs(role=tablist/tab/tabpanel, arrow-key nav),Gallerylightbox (role=dialog, Escape close, focus trap, keyboard-operable thumbnails). Model onAccordion's native<details>. Tests assert ARIA intoHtml. Commit per component. - F2 — Exported semantics. Form
<label for>/inputidassociation (InputField/TextareaField/ContactForm);StarRatingrole="img"+aria-label;Navbarhamburger accessible name +aria-expanded; iframetitle(VideoBlock/MapEmbed);aria-hiddenon decorative FA<i>. Tests. Commit. - F3 — Editor chrome a11y. Add
role="button"/tabIndex/key handlers to clickable<div>rows (PagesPanel,LayersPanel,AssetsPanel,TemplateModalcards,ImageStylePanel/AssetPicker thumbnails);aria-labelon topbar icon buttons. Commit. - F4 — Confirm + clipboard.
AssetsPaneldelete: add an in-app confirm consistent with the WHP no-native-dialog rule (reuse the app's existing confirm pattern, NOTwindow.confirm); also replace the existingwindow.confirminSitesmithModal.tsx:85.copyUrlgets a.catch+ fallback. Commit. - F5 — Menu deterministic id.
basic/Menu.tsx:482: replaceMath.random()id with a stable id (hash of content/props or a passed nodeId), matching Navbar. Test: two exports of unchanged Menu are byte-identical. Commit.
Phase G — Low. Last.
- G1 — GuidedStyles cruft.
panels/right/GuidedStyles.tsx: remove unusedresolvedName/resolverMap; anchor/hero/ilike siblings; dropisContainermatch for the removed Header/Footer Zone. Commit. - G2 — Dev proxy.
vite.config.ts:29: point/apiproxy atwhp-al10-test(192.168.1.148:8080). Commit. - G3 — Context-menu shortcuts. Either implement Ctrl+C/Ctrl+V in
useKeyboardShortcuts.ts(using B1's regen helper for paste) or remove the advertised hints inContextMenu.tsx:193,199. Prefer implementing to match the UI. Test. Commit. - G4 — Script nits. ContentSlider autoplay: pause on hover/visibility + clear interval; Countdown: stop at zero; iframe
src: emit&correctly. Commit.
Final verification (before report)
cd craft && npx vitest run— all green; note count.cd craft && npm run build— tsc strict + vite succeed.grep -rE "const (esc|escapeHtml)\s*=|function (esc|escapeHtml)" craft/src→ onlyescape.ts.- Manual
npm run devsmoke: XSS payload in a text/href/date field is neutralized in exported HTML; duplicate/paste produces fresh ids; header edit + save persists to header slot; every image field offers Upload+Browse+URL. - Build the site-builder bundle and stage per
whp-deploy-release(deploy itself is a separate, user-gated step — the report will note readiness, not auto-deploy). - Update
craft/CLAUDE.md(component count/list) and this plan's checkboxes.
Self-review notes
- Spec coverage: security spec §1→A1-A5, §2→B1, §3→B2, §4→B3; asset-picker spec→C1-C4; Medium/Low backlog→D/E/F/G (each backlog bullet maps to a task).
- Dependency ordering: A before C/D/E/F/G (escape util + shared-file edits); C before E (don't delete picker as dead); B independent.
- Deploy is intentionally NOT automated here — user gates production per repo discipline.